From 548a75e606b6c9def1276250ba98bbb7d45c58aa Mon Sep 17 00:00:00 2001 From: Ed Snible Date: Thu, 20 Aug 2026 11:57:23 -0400 Subject: [PATCH 1/7] feat(praxis): Generate Praxis proxy + policy configs from AuthBridge config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add authlib/praxis, which converts an AuthBridge config.Config into a Praxis proxy configuration (github.com/praxis-proxy/praxis), plus a cmd/authbridge-praxis binary and container image that generate the config and hand off to Praxis as the data plane. What is translated faithfully: listener addresses, the application backend, routing, mTLS posture (permissive -> client_cert_mode: request, strict -> require, certs from the SPIFFE mirror), and the admin/metrics endpoint. Inbound JWT validation is enforced via a generated Praxis *policy document*: jwt-validation becomes an identity/jwt policy plugin with on_error: fail, reusing the plugin's own JWKS derivation priority (explicit jwks_url -> keycloak_url+realm -> issuer) and its OR-semantics audience union, so the generated policy verifies the same tokens. Verified end to end against a policy-engine build: unauthenticated requests get 401 with WWW-Authenticate: Bearer and X-Policy-Violation. What is NOT translated is reported rather than dropped silently, in Result.Unmapped, as WARN logs, and as comments in the generated files. A generated proxy that no longer enforces what AuthBridge enforced is exactly the kind of difference that must not be discoverable only by reading the output file. Notably: - token-exchange (RFC 8693) has no default-build counterpart; Praxis's policy filter covers delegation but needs APL routes and a delegator. - The forward proxy resolves each destination at request time, which Praxis's cluster-selecting router cannot express; the generated outbound listener carries a static_response placeholder so it is explicitly closed rather than accidentally open. - inbound_interception: transparent relies on SO_ORIGINAL_DST. - audience_file / audience_mode: per-host cannot be resolved at generation time, so the policy would not validate aud. - bypass_paths has no identity-plugin equivalent, so health probes to those paths would now require a token. Two safety-relevant details are surfaced loudly rather than papered over: a plaintext http:// JWKS endpoint needs decoding_key.insecure_http (the policy engine rejects http:// otherwise), and mtls without a spiffe block means nothing writes /opt/svid.pem. The admin endpoint binds AuthBridge's health port (9091), not the stats port, because Praxis's /ready and /healthy correspond to /readyz and /healthz — so an existing readiness probe keeps working. The image builds Praxis with --features policy-engine, which is off by default upstream and is what compiles in the policy filter; a build-time probe fails the image if that feature did not take effect, since a default-feature Praxis would reject exactly the configs that need inbound auth. Correctness is pinned against the real Praxis binary rather than Go-side assertions alone: tests shell out to it to validate 13 generated configurations, covering rules (filter ordering, cluster cross-references, field names, admin loopback) that no mock could reproduce. Those tests skip when no binary is available, so CI without a Rust toolchain still passes. Signed-off-by: Ed Snible Assisted-By: Claude (Anthropic AI) --- authbridge/authlib/praxis/policy.go | 417 ++++++++ authbridge/authlib/praxis/policy_test.go | 592 +++++++++++ authbridge/authlib/praxis/praxis.go | 934 ++++++++++++++++++ authbridge/authlib/praxis/praxis_test.go | 874 ++++++++++++++++ authbridge/authlib/praxis/render.go | 190 ++++ authbridge/cmd/authbridge-praxis/Dockerfile | 142 +++ .../cmd/authbridge-praxis/entrypoint.sh | 76 ++ authbridge/cmd/authbridge-praxis/go.mod | 37 + authbridge/cmd/authbridge-praxis/go.sum | 89 ++ authbridge/cmd/authbridge-praxis/main.go | 271 +++++ authbridge/go.work | 1 + 11 files changed, 3623 insertions(+) create mode 100644 authbridge/authlib/praxis/policy.go create mode 100644 authbridge/authlib/praxis/policy_test.go create mode 100644 authbridge/authlib/praxis/praxis.go create mode 100644 authbridge/authlib/praxis/praxis_test.go create mode 100644 authbridge/authlib/praxis/render.go create mode 100644 authbridge/cmd/authbridge-praxis/Dockerfile create mode 100644 authbridge/cmd/authbridge-praxis/entrypoint.sh create mode 100644 authbridge/cmd/authbridge-praxis/go.mod create mode 100644 authbridge/cmd/authbridge-praxis/go.sum create mode 100644 authbridge/cmd/authbridge-praxis/main.go diff --git a/authbridge/authlib/praxis/policy.go b/authbridge/authlib/praxis/policy.go new file mode 100644 index 000000000..23e49fcfe --- /dev/null +++ b/authbridge/authlib/praxis/policy.go @@ -0,0 +1,417 @@ +package praxis + +import ( + "bytes" + "encoding/json" + "fmt" + "net/url" + "strings" + + "github.com/rossoctl/cortex/authbridge/authlib/config" +) + +// This file generates the Praxis *policy document* — the second file the +// `policy` filter needs, referenced by its `config_path`. It is a separate +// document from the proxy config: the proxy config declares the filter chain, +// and the policy document declares the identity plugins and routes the policy +// engine enforces. +// +// The `policy` filter requires Praxis to be built with the `policy-engine` +// cargo feature: +// +// cargo run --features policy-engine -p praxis-proxy -- -c /tmp/praxis-config.yaml +// +// A default-feature build rejects the filter with "unknown filter type: +// 'policy'", so [Convert] only emits it when a policy document is actually +// being generated alongside — see [Options.PolicyPath]. + +// Policy plugin wiring constants. These strings are part of the policy +// engine's public API: `identity/jwt` selects the JWT identity resolver, and +// `identity.resolve` is the hook it runs on. +const ( + policyPluginKindJWT = "identity/jwt" + policyHookIdentity = "identity.resolve" + policyModeSequential = "sequential" + policyOnErrorFail = "fail" + policyClaimMapperStd = "standard" + policyDefaultLeewaySec = 60 + + // policyJWTPluginPriority orders the identity plugin within its mode + // band; lower runs first. Identity resolution must precede anything that + // reads the resolved identity, so it takes a low number. + policyJWTPluginPriority = 10 + + // policyJWKSRefreshSecs matches the policy engine's own default (10 + // minutes): high enough not to hammer the IdP, low enough that a routine + // key rotation propagates within a change window. Emitted explicitly so + // the generated document states its refresh cadence rather than relying + // on an upstream default that could change. + policyJWKSRefreshSecs = 600 +) + +// PolicyDocument is the top-level Praxis policy document (the policy engine's +// "unified config"). Only the fields this converter populates are modeled. +// +// Routing is deliberately left off: with `plugin_settings.routing_enabled` +// absent (false), the engine resolves identity on the request phase and denies +// on a missing or invalid JWT, which is exactly AuthBridge's jwt-validation +// semantics. Turning routing on would shift enforcement to per-entity routes +// and require protocol-classifier metadata, changing behavior rather than +// translating it. +type PolicyDocument struct { + Plugins []PolicyPlugin `yaml:"plugins"` +} + +// PolicyPlugin is one plugin declaration in the policy document. +type PolicyPlugin struct { + Name string `yaml:"name"` + Kind string `yaml:"kind"` + Description string `yaml:"description,omitempty"` + // Hooks are the hook names this plugin handles, e.g. identity.resolve. + Hooks []string `yaml:"hooks"` + // Mode is the execution band: sequential can both block and modify, + // which is what a deny-capable identity gate needs. + Mode string `yaml:"mode,omitempty"` + // Priority orders plugins within a mode band; lower runs first. + Priority int `yaml:"priority,omitempty"` + // OnError is the failure posture. "fail" halts the pipeline and + // propagates the error — fail-closed, matching AuthBridge's rejection of + // requests it cannot validate. + OnError string `yaml:"on_error,omitempty"` + // Config is the plugin's own typed configuration. + Config any `yaml:"config,omitempty"` +} + +// JWTIdentityConfig is the `identity/jwt` plugin's configuration. +type JWTIdentityConfig struct { + // Header is the request header the token is read from; the "Bearer " + // prefix is stripped if present. + Header string `yaml:"header"` + // ClaimMapper selects how claims map onto the identity. "standard" is + // the OIDC default. + ClaimMapper string `yaml:"claim_mapper,omitempty"` + // TrustedIssuers is the set of accepted issuers. At least one required. + TrustedIssuers []TrustedIssuer `yaml:"trusted_issuers"` +} + +// TrustedIssuer is one accepted issuer: which `iss` to expect, which `aud` +// values to accept, which algorithms, and where the verification key comes +// from. +type TrustedIssuer struct { + Issuer string `yaml:"issuer"` + // Audiences are the accepted `aud` values (OR semantics). An empty list + // disables audience validation upstream, so this converter never emits + // an empty list without saying so — see [BuildPolicy]. + Audiences []string `yaml:"audiences,omitempty"` + Algorithms []string `yaml:"algorithms"` + DecodingKey DecodingKey `yaml:"decoding_key"` + // LeewaySeconds is the clock-skew tolerance for exp/nbf validation. + LeewaySeconds int `yaml:"leeway_seconds,omitempty"` +} + +// DecodingKey is where JWT signing key material comes from. This converter +// emits the `jwks_url` form, since AuthBridge's jwt-validation verifies +// against a JWKS endpoint. +type DecodingKey struct { + Kind string `yaml:"kind"` + URL string `yaml:"url,omitempty"` + // InsecureHTTP permits a plaintext http:// JWKS URL. The policy engine + // rejects http:// JWKS endpoints unless this is set, because anyone on + // the network path could swap the key material and forge accepted JWTs. + InsecureHTTP bool `yaml:"insecure_http,omitempty"` + // RefreshSecs is how often the background task refetches the key set. + RefreshSecs int `yaml:"refresh_secs,omitempty"` +} + +// jwtValidationConfig mirrors the subset of AuthBridge's jwt-validation plugin +// config that the policy translation consumes. Decoded from the plugin entry's +// raw config subtree; unknown fields are ignored here because the plugin's own +// typed decode is the authority on validity. +type jwtValidationConfig struct { + Issuer string `json:"issuer"` + JWKSURL string `json:"jwks_url"` + KeycloakURL string `json:"keycloak_url"` + KeycloakRealm string `json:"keycloak_realm"` + Audience string `json:"audience"` + AudienceFile string `json:"audience_file"` + AudienceMode string `json:"audience_mode"` + AllowedAudiences []string `json:"allowed_audiences"` + BypassPaths []string `json:"bypass_paths"` +} + +// resolveJWKSURL reproduces jwt-validation's derivation priority so the +// generated policy fetches keys from the same endpoint AuthBridge would: +// +// 1. explicit jwks_url +// 2. keycloak_url + keycloak_realm (the internal URL, for split-horizon) +// 3. issuer (single-horizon fallback) +// +// Keeping this in step with the plugin matters: pointing the policy at a +// different JWKS endpoint than AuthBridge used would either fail to verify +// tokens that AuthBridge accepted, or verify against the wrong key material. +func (c jwtValidationConfig) resolveJWKSURL() string { + if c.JWKSURL != "" { + return c.JWKSURL + } + if c.KeycloakURL != "" && c.KeycloakRealm != "" { + return strings.TrimRight(c.KeycloakURL, "/") + "/realms/" + c.KeycloakRealm + + "/protocol/openid-connect/certs" + } + if c.Issuer != "" { + return strings.TrimRight(c.Issuer, "/") + "/protocol/openid-connect/certs" + } + return "" +} + +// audiences returns the accepted audience values, mirroring jwt-validation's +// OR semantics: allowed_audiences entries in config order, then the literal +// audience. Deduplicated, first occurrence winning. +// +// audience_file is NOT resolved here: it names a file read at runtime (the +// Rossoctl client-id convention), and this converter does not read the +// filesystem — a generated policy that silently baked in whatever happened to +// be on the generating machine's disk would be worse than one that reports the +// gap. Callers see it via [PolicyResult.Warnings]. +func (c jwtValidationConfig) audiences() []string { + var ( + out []string + seen = map[string]bool{} + ) + add := func(s string) { + if s == "" || seen[s] { + return + } + seen[s] = true + out = append(out, s) + } + for _, a := range c.AllowedAudiences { + add(a) + } + add(c.Audience) + return out +} + +// PolicyResult is the outcome of building a policy document. +type PolicyResult struct { + // Document is the generated policy document. Nil when no AuthBridge + // plugin in the pipeline maps onto a policy plugin, in which case no + // policy file should be written and no `policy` filter emitted. + Document *PolicyDocument + // Enforced lists the AuthBridge plugins whose enforcement the policy + // document carries, e.g. "jwt-validation". + Enforced []string + // Warnings records fidelity gaps in the generated policy — settings that + // could not be translated exactly and that change what the proxy accepts. + Warnings []string +} + +// BuildPolicy generates a Praxis policy document enforcing JWT validation from +// an AuthBridge config's inbound pipeline. +// +// It reads the `jwt-validation` plugin's config (issuer, JWKS URL derivation, +// audiences) and emits an `identity/jwt` policy plugin that verifies the same +// tokens: same issuer, same audiences, same JWKS endpoint, fail-closed on a +// missing or invalid token. +// +// Returns a Document of nil when the inbound pipeline declares no plugin that +// maps onto a policy plugin — there is nothing to enforce, so no policy file +// should be written. A jwt-validation entry disabled with `on_error: off` is +// skipped, matching AuthBridge dropping it from the pipeline. +// +// An error is returned only when a jwt-validation plugin is present but cannot +// be translated into something that would actually verify tokens (no issuer, +// or no derivable JWKS URL) — emitting a policy that accepts everything, or +// one the engine rejects at startup, would both be worse than failing here. +func BuildPolicy(cfg *config.Config) (*PolicyResult, error) { + if cfg == nil { + return nil, fmt.Errorf("praxis: nil AuthBridge config") + } + res := &PolicyResult{} + + for _, p := range cfg.Pipeline.Inbound.Plugins { + if p.Name != "jwt-validation" || p.OnError == "off" { + continue + } + plugin, warnings, err := jwtPolicyPlugin(p) + if err != nil { + return nil, err + } + if res.Document == nil { + res.Document = &PolicyDocument{} + } + res.Document.Plugins = append(res.Document.Plugins, plugin) + res.Enforced = append(res.Enforced, p.Name) + res.Warnings = append(res.Warnings, warnings...) + } + + return res, nil +} + +// jwtPolicyPlugin translates one jwt-validation entry into an identity/jwt +// policy plugin. +func jwtPolicyPlugin(entry config.PluginEntry) (PolicyPlugin, []string, error) { + var jc jwtValidationConfig + if len(entry.Config) > 0 { + if err := json.Unmarshal(entry.Config, &jc); err != nil { + return PolicyPlugin{}, nil, fmt.Errorf( + "praxis: decoding jwt-validation config: %w", err) + } + } + + if jc.Issuer == "" { + return PolicyPlugin{}, nil, fmt.Errorf( + "praxis: jwt-validation has no issuer, so no policy could be generated that " + + "verifies tokens; set issuer in the plugin config") + } + jwks := jc.resolveJWKSURL() + if jwks == "" { + return PolicyPlugin{}, nil, fmt.Errorf( + "praxis: jwt-validation jwks_url could not be derived for issuer %q; set "+ + "jwks_url, or keycloak_url + keycloak_realm", jc.Issuer) + } + + var warnings []string + + // The policy engine rejects a plaintext http:// JWKS URL unless + // insecure_http is set. AuthBridge does not have this guard, so a local / + // demo config that works under AuthBridge would fail Praxis startup + // outright. Set the flag to preserve behavior, and say so — over plaintext + // anyone on the path can swap the key material and forge accepted JWTs. + insecureHTTP := false + if u, err := url.Parse(jwks); err == nil && u.Scheme == "http" { + insecureHTTP = true + warnings = append(warnings, fmt.Sprintf( + "jwt-validation JWKS endpoint %q is plaintext http://, so the generated policy sets "+ + "decoding_key.insecure_http: true (the policy engine rejects http:// JWKS URLs "+ + "otherwise). Anyone on the network path to that endpoint can substitute key "+ + "material and forge tokens this proxy will accept. Acceptable for local "+ + "development; use https for anything else.", jwks)) + } + + auds := jc.audiences() + if len(auds) == 0 { + // Upstream treats an empty audiences list as "disable aud validation". + // AuthBridge always validates audience, so an empty list here is a + // real weakening and must be reported rather than quietly emitted. + switch { + case jc.AudienceMode == "per-host": + warnings = append(warnings, + "jwt-validation uses audience_mode: per-host, which derives the expected "+ + "audience from each request's Host. The policy engine's trusted_issuers take "+ + "a static audience list, so the generated policy does NOT validate the aud "+ + "claim. Add the concrete audiences to trusted_issuers[0].audiences.") + case jc.AudienceFile != "": + warnings = append(warnings, fmt.Sprintf( + "jwt-validation reads its expected audience from the file %q at runtime, which "+ + "this converter does not read. The generated policy does NOT validate the "+ + "aud claim — any token from the trusted issuer is accepted regardless of "+ + "audience. Set `audience` explicitly in the plugin config, or add the value "+ + "to trusted_issuers[0].audiences in the generated policy.", jc.AudienceFile)) + default: + warnings = append(warnings, + "jwt-validation declares no audience, so the generated policy does NOT validate "+ + "the aud claim: any token from the trusted issuer is accepted.") + } + } + + if len(jc.BypassPaths) > 0 { + // jwt-validation skips validation on these path globs. The identity + // plugin has no per-path exemption, so those paths would now require a + // token. That flips health probes and .well-known endpoints from open + // to 401 — worth stating explicitly. + warnings = append(warnings, fmt.Sprintf( + "jwt-validation exempts the paths %v from validation, which the identity plugin "+ + "has no equivalent for: in the generated policy those paths require a valid "+ + "token too. Health and readiness probes to them will get 401. Praxis serves "+ + "its own liveness/readiness on the admin endpoint, which does not run this "+ + "chain.", jc.BypassPaths)) + } + + return PolicyPlugin{ + Name: "jwt-validation", + Kind: policyPluginKindJWT, + Description: "Inbound JWT validation, translated from AuthBridge's jwt-validation plugin.", + Hooks: []string{policyHookIdentity}, + Mode: policyModeSequential, + Priority: policyJWTPluginPriority, + OnError: policyOnErrorFail, + Config: JWTIdentityConfig{ + Header: "Authorization", + ClaimMapper: policyClaimMapperStd, + TrustedIssuers: []TrustedIssuer{{ + Issuer: jc.Issuer, + Audiences: auds, + Algorithms: []string{"RS256"}, + DecodingKey: DecodingKey{ + Kind: "jwks_url", + URL: jwks, + InsecureHTTP: insecureHTTP, + RefreshSecs: policyJWKSRefreshSecs, + }, + LeewaySeconds: policyDefaultLeewaySec, + }}, + }, + }, warnings, nil +} + +// policyHeader is the comment block prepended to a generated policy document. +const policyHeader = `# Praxis POLICY DOCUMENT — GENERATED from an AuthBridge config. +# +# Generated by authbridge/authlib/praxis (BuildPolicy). Edits are overwritten +# on the next run; change the AuthBridge config instead. +# +# This is the document the proxy config's ` + "`policy`" + ` filter loads via its +# ` + "`config_path`" + `. It declares the identity plugins the Praxis Policy Engine +# enforces — here, inbound JWT validation translated from AuthBridge's +# jwt-validation plugin. +# +# Requires Praxis built with the policy-engine cargo feature: +# cargo run --features policy-engine -p praxis-proxy -- -c %s +# +# Enforcement: the engine resolves identity on the request phase and denies a +# missing or invalid token with 401 (WWW-Authenticate: Bearer, plus an +# X-Policy-Violation header naming the failure). ` + "`on_error: fail`" + ` makes the +# plugin fail closed. +# +# Routing is deliberately not enabled. With plugin_settings.routing_enabled +# absent, identity resolution alone gates every request — which is what +# AuthBridge's jwt-validation does. Enabling routes would move enforcement to +# per-entity rules and require protocol-classifier metadata in the chain. +` + +// RenderPolicy marshals a policy document to YAML with an explanatory header. +// +// proxyConfigPath is used only to make the header's example command +// copy-pasteable. +func RenderPolicy(doc *PolicyDocument, proxyConfigPath string) ([]byte, error) { + if doc == nil { + return nil, fmt.Errorf("praxis: nil policy document") + } + return renderYAML(fmt.Sprintf(policyHeader, proxyConfigPath), doc) +} + +// RenderPolicyResult marshals a policy document, appending the fidelity +// warnings as a trailing comment block so the generated file carries its own +// caveats — the places where it accepts more than AuthBridge would. +func RenderPolicyResult(res *PolicyResult, proxyConfigPath string) ([]byte, error) { + if res == nil || res.Document == nil { + return nil, fmt.Errorf("praxis: no policy document to render") + } + out, err := RenderPolicy(res.Document, proxyConfigPath) + if err != nil { + return nil, err + } + if len(res.Warnings) == 0 { + return out, nil + } + var buf bytes.Buffer + buf.Write(out) + buf.WriteString("\n# ── POLICY FIDELITY NOTES ") + buf.WriteString(strings.Repeat("─", 45)) + buf.WriteString("\n#\n") + buf.WriteString("# Where this policy differs from the AuthBridge config it came from:\n#\n") + for _, w := range res.Warnings { + writeCommentBlock(&buf, w) + } + return buf.Bytes(), nil +} diff --git a/authbridge/authlib/praxis/policy_test.go b/authbridge/authlib/praxis/policy_test.go new file mode 100644 index 000000000..45cedd8ff --- /dev/null +++ b/authbridge/authlib/praxis/policy_test.go @@ -0,0 +1,592 @@ +package praxis + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/rossoctl/cortex/authbridge/authlib/config" + "gopkg.in/yaml.v3" +) + +// jwtPlugin builds a jwt-validation plugin entry with the given config. +func jwtPlugin(t *testing.T, cfg map[string]any) config.PluginEntry { + t.Helper() + raw, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal plugin config: %v", err) + } + return config.PluginEntry{Name: "jwt-validation", Config: raw} +} + +// keycloakJWT is the shape the weather-service example uses: public issuer, +// internal Keycloak URL + realm for JWKS derivation, explicit audience. +func keycloakJWT(t *testing.T) config.PluginEntry { + t.Helper() + return jwtPlugin(t, map[string]any{ + "issuer": "http://keycloak.localtest.me:8080/realms/rossoctl", + "keycloak_url": "http://keycloak.localtest.me:8080/", + "keycloak_realm": "rossoctl", + "audience": "spiffe://localtest.me/ns/team1/sa/weather-service", + }) +} + +func TestBuildPolicy_NilConfig(t *testing.T) { + if _, err := BuildPolicy(nil); err == nil { + t.Fatal("expected an error for a nil config") + } +} + +// No jwt-validation in the pipeline means nothing for the policy engine to +// enforce, so no document should be produced — writing one, and pointing a +// policy filter at it, would add a filter that enforces nothing. +func TestBuildPolicy_NoJWTPlugin_NoDocument(t *testing.T) { + res, err := BuildPolicy(proxySidecar(t, nil)) + if err != nil { + t.Fatalf("BuildPolicy: %v", err) + } + if res.Document != nil { + t.Errorf("expected no policy document, got %+v", res.Document) + } + if len(res.Enforced) != 0 { + t.Errorf("expected nothing enforced, got %v", res.Enforced) + } +} + +func TestBuildPolicy_JWTValidation(t *testing.T) { + res, err := BuildPolicy(proxySidecar(t, func(c *config.Config) { + c.Pipeline.Inbound.Plugins = []config.PluginEntry{keycloakJWT(t)} + })) + if err != nil { + t.Fatalf("BuildPolicy: %v", err) + } + if res.Document == nil { + t.Fatal("expected a policy document") + } + if len(res.Document.Plugins) != 1 { + t.Fatalf("plugins = %d, want 1", len(res.Document.Plugins)) + } + p := res.Document.Plugins[0] + if p.Kind != policyPluginKindJWT { + t.Errorf("kind = %q, want %q", p.Kind, policyPluginKindJWT) + } + if len(p.Hooks) != 1 || p.Hooks[0] != policyHookIdentity { + t.Errorf("hooks = %v, want [%s]", p.Hooks, policyHookIdentity) + } + // Fail-closed is the whole point: a plugin that ignores its own errors + // would let unvalidated requests through. + if p.OnError != policyOnErrorFail { + t.Errorf("on_error = %q, want %q", p.OnError, policyOnErrorFail) + } + // sequential is the only band that can both block and modify. + if p.Mode != policyModeSequential { + t.Errorf("mode = %q, want %q", p.Mode, policyModeSequential) + } + + jc, ok := p.Config.(JWTIdentityConfig) + if !ok { + t.Fatalf("config has type %T", p.Config) + } + ti := jc.TrustedIssuers[0] + if ti.Issuer != "http://keycloak.localtest.me:8080/realms/rossoctl" { + t.Errorf("issuer = %q", ti.Issuer) + } + if len(ti.Audiences) != 1 || ti.Audiences[0] != "spiffe://localtest.me/ns/team1/sa/weather-service" { + t.Errorf("audiences = %v", ti.Audiences) + } + // JWKS must be derived from keycloak_url + keycloak_realm, matching + // jwt-validation's own derivation. + want := "http://keycloak.localtest.me:8080/realms/rossoctl/protocol/openid-connect/certs" + if ti.DecodingKey.URL != want { + t.Errorf("jwks url = %q, want %q", ti.DecodingKey.URL, want) + } + if ti.DecodingKey.Kind != "jwks_url" { + t.Errorf("decoding_key kind = %q, want jwks_url", ti.DecodingKey.Kind) + } + if !containsSubstring(res.Enforced, "jwt-validation") { + t.Errorf("Enforced = %v, want jwt-validation", res.Enforced) + } +} + +// The policy engine rejects a plaintext http:// JWKS URL unless insecure_http +// is set, so a local config that works under AuthBridge would otherwise fail +// Praxis startup. The flag must be set AND the weakening reported. +func TestBuildPolicy_PlaintextJWKS_SetsInsecureAndWarns(t *testing.T) { + res, err := BuildPolicy(proxySidecar(t, func(c *config.Config) { + c.Pipeline.Inbound.Plugins = []config.PluginEntry{keycloakJWT(t)} + })) + if err != nil { + t.Fatalf("BuildPolicy: %v", err) + } + jc := res.Document.Plugins[0].Config.(JWTIdentityConfig) + if !jc.TrustedIssuers[0].DecodingKey.InsecureHTTP { + t.Error("expected insecure_http for a plaintext http:// JWKS URL") + } + if !containsSubstring(res.Warnings, "insecure_http") { + t.Errorf("expected a warning about plaintext JWKS, got %v", res.Warnings) + } +} + +func TestBuildPolicy_HTTPSJWKS_NoInsecureFlag(t *testing.T) { + res, err := BuildPolicy(proxySidecar(t, func(c *config.Config) { + c.Pipeline.Inbound.Plugins = []config.PluginEntry{jwtPlugin(t, map[string]any{ + "issuer": "https://idp.example.com/realms/r", + "jwks_url": "https://idp.example.com/realms/r/protocol/openid-connect/certs", + "audience": "svc", + })} + })) + if err != nil { + t.Fatalf("BuildPolicy: %v", err) + } + jc := res.Document.Plugins[0].Config.(JWTIdentityConfig) + if jc.TrustedIssuers[0].DecodingKey.InsecureHTTP { + t.Error("https JWKS must not set insecure_http") + } + if containsSubstring(res.Warnings, "insecure_http") { + t.Errorf("https JWKS should not warn about plaintext: %v", res.Warnings) + } +} + +// An explicit jwks_url wins over keycloak_url derivation, matching +// jwt-validation's priority order. +func TestBuildPolicy_ExplicitJWKSWins(t *testing.T) { + res, err := BuildPolicy(proxySidecar(t, func(c *config.Config) { + c.Pipeline.Inbound.Plugins = []config.PluginEntry{jwtPlugin(t, map[string]any{ + "issuer": "https://public.example.com/realms/r", + "jwks_url": "https://internal.svc/jwks", + "keycloak_url": "https://other.example.com", + "keycloak_realm": "r", + "audience": "svc", + })} + })) + if err != nil { + t.Fatalf("BuildPolicy: %v", err) + } + jc := res.Document.Plugins[0].Config.(JWTIdentityConfig) + if got := jc.TrustedIssuers[0].DecodingKey.URL; got != "https://internal.svc/jwks" { + t.Errorf("jwks url = %q, want the explicit one", got) + } +} + +// Falling back to the issuer is jwt-validation's third derivation step. +func TestBuildPolicy_JWKSFromIssuer(t *testing.T) { + res, err := BuildPolicy(proxySidecar(t, func(c *config.Config) { + c.Pipeline.Inbound.Plugins = []config.PluginEntry{jwtPlugin(t, map[string]any{ + "issuer": "https://idp.example.com/realms/r", + "audience": "svc", + })} + })) + if err != nil { + t.Fatalf("BuildPolicy: %v", err) + } + jc := res.Document.Plugins[0].Config.(JWTIdentityConfig) + want := "https://idp.example.com/realms/r/protocol/openid-connect/certs" + if got := jc.TrustedIssuers[0].DecodingKey.URL; got != want { + t.Errorf("jwks url = %q, want %q", got, want) + } +} + +// allowed_audiences and audience are unioned with OR semantics, deduplicated. +func TestBuildPolicy_AudienceUnion(t *testing.T) { + res, err := BuildPolicy(proxySidecar(t, func(c *config.Config) { + c.Pipeline.Inbound.Plugins = []config.PluginEntry{jwtPlugin(t, map[string]any{ + "issuer": "https://idp.example.com/realms/r", + "audience": "primary", + "allowed_audiences": []string{"extra-1", "extra-2", "primary"}, + })} + })) + if err != nil { + t.Fatalf("BuildPolicy: %v", err) + } + jc := res.Document.Plugins[0].Config.(JWTIdentityConfig) + got := jc.TrustedIssuers[0].Audiences + want := []string{"extra-1", "extra-2", "primary"} + if len(got) != len(want) { + t.Fatalf("audiences = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("audiences = %v, want %v", got, want) + break + } + } +} + +// An audience_file is read at runtime by AuthBridge and cannot be resolved +// here. The resulting policy does NOT validate aud, which is a real weakening +// and must be reported rather than passing silently. +func TestBuildPolicy_AudienceFile_WarnsAboutNoAudValidation(t *testing.T) { + res, err := BuildPolicy(proxySidecar(t, func(c *config.Config) { + c.Pipeline.Inbound.Plugins = []config.PluginEntry{jwtPlugin(t, map[string]any{ + "issuer": "https://idp.example.com/realms/r", + "audience_file": "/shared/client-id.txt", + })} + })) + if err != nil { + t.Fatalf("BuildPolicy: %v", err) + } + jc := res.Document.Plugins[0].Config.(JWTIdentityConfig) + if len(jc.TrustedIssuers[0].Audiences) != 0 { + t.Errorf("expected no audiences, got %v", jc.TrustedIssuers[0].Audiences) + } + if !containsSubstring(res.Warnings, "does NOT validate") { + t.Errorf("expected a warning that aud is unvalidated, got %v", res.Warnings) + } + if !containsSubstring(res.Warnings, "/shared/client-id.txt") { + t.Errorf("expected the warning to name the file, got %v", res.Warnings) + } +} + +func TestBuildPolicy_PerHostAudience_Warns(t *testing.T) { + res, err := BuildPolicy(proxySidecar(t, func(c *config.Config) { + c.Pipeline.Inbound.Plugins = []config.PluginEntry{jwtPlugin(t, map[string]any{ + "issuer": "https://idp.example.com/realms/r", + "audience_mode": "per-host", + })} + })) + if err != nil { + t.Fatalf("BuildPolicy: %v", err) + } + if !containsSubstring(res.Warnings, "per-host") { + t.Errorf("expected a per-host warning, got %v", res.Warnings) + } +} + +// bypass_paths has no identity-plugin equivalent, so those paths now require a +// token. Health probes flipping from open to 401 must be surfaced. +func TestBuildPolicy_BypassPaths_Warns(t *testing.T) { + res, err := BuildPolicy(proxySidecar(t, func(c *config.Config) { + c.Pipeline.Inbound.Plugins = []config.PluginEntry{jwtPlugin(t, map[string]any{ + "issuer": "https://idp.example.com/realms/r", + "audience": "svc", + "bypass_paths": []string{"/healthz", "/metrics"}, + })} + })) + if err != nil { + t.Fatalf("BuildPolicy: %v", err) + } + if !containsSubstring(res.Warnings, "/healthz") { + t.Errorf("expected a bypass_paths warning naming the paths, got %v", res.Warnings) + } +} + +// Generating a policy that cannot verify anything would be worse than failing: +// it would look like enforcement while accepting every token. +func TestBuildPolicy_MissingIssuer_IsError(t *testing.T) { + _, err := BuildPolicy(proxySidecar(t, func(c *config.Config) { + c.Pipeline.Inbound.Plugins = []config.PluginEntry{jwtPlugin(t, map[string]any{ + "audience": "svc", + })} + })) + if err == nil { + t.Fatal("expected an error when jwt-validation has no issuer") + } + if !strings.Contains(err.Error(), "issuer") { + t.Errorf("error should mention issuer: %v", err) + } +} + +// on_error: off means AuthBridge drops the plugin, so no policy is generated +// for it — otherwise the Praxis proxy would enforce what AuthBridge did not. +func TestBuildPolicy_DisabledPlugin_NoDocument(t *testing.T) { + res, err := BuildPolicy(proxySidecar(t, func(c *config.Config) { + e := keycloakJWT(t) + e.OnError = "off" + c.Pipeline.Inbound.Plugins = []config.PluginEntry{e} + })) + if err != nil { + t.Fatalf("BuildPolicy: %v", err) + } + if res.Document != nil { + t.Error("a plugin disabled with on_error: off must not produce a policy") + } +} + +// With a policy document, the inbound chain must carry the `policy` filter +// instead of the inert UNMAPPED marker, and jwt-validation must no longer be +// reported unmapped. +func TestConvertWithPolicy_EmitsPolicyFilter(t *testing.T) { + cfg := proxySidecar(t, func(c *config.Config) { + c.Pipeline.Inbound.Plugins = []config.PluginEntry{keycloakJWT(t)} + }) + res, pol, err := ConvertWithPolicy(cfg, "/tmp/praxis-policy.yaml") + if err != nil { + t.Fatalf("ConvertWithPolicy: %v", err) + } + if pol.Document == nil { + t.Fatal("expected a policy document") + } + if containsSubstring(res.Unmapped, "jwt-validation") { + t.Errorf("jwt-validation is enforced by the policy and must not be unmapped: %v", res.Unmapped) + } + + var found *Filter + for i, f := range res.Config.FilterChains[0].Filters { + if f.Type == "policy" { + found = &res.Config.FilterChains[0].Filters[i] + } + } + if found == nil { + t.Fatal("expected a policy filter in the inbound chain") + } + var gotPath any + var gotMeta any + for _, fl := range found.Fields { + switch fl.Key { + case "config_path": + gotPath = fl.Value + case "require_protocol_metadata": + gotMeta = fl.Value + } + } + if gotPath != "/tmp/praxis-policy.yaml" { + t.Errorf("config_path = %v, want the policy path", gotPath) + } + // True (the upstream default) would reject every request for missing + // classifier metadata rather than judging it on its token. + if gotMeta != false { + t.Errorf("require_protocol_metadata = %v, want false", gotMeta) + } +} + +// Without a policy path, the auth plugin stays unmapped and no policy filter is +// emitted — a default-feature Praxis build must still get a loadable config. +func TestConvertWithoutPolicy_NoPolicyFilter(t *testing.T) { + res, err := Convert(proxySidecar(t, func(c *config.Config) { + c.Pipeline.Inbound.Plugins = []config.PluginEntry{keycloakJWT(t)} + }), nil) + if err != nil { + t.Fatalf("Convert: %v", err) + } + for _, f := range res.Config.FilterChains[0].Filters { + if f.Type == "policy" { + t.Error("no policy filter should be emitted without a policy path") + } + } + if !containsSubstring(res.Unmapped, "jwt-validation") { + t.Errorf("expected jwt-validation unmapped, got %v", res.Unmapped) + } +} + +// A pipeline with no enforceable plugin must not get a policy filter, since it +// would point at a file the caller never writes and Praxis would fail to start. +func TestConvertWithPolicy_NoEnforceable_NoFilter(t *testing.T) { + res, pol, err := ConvertWithPolicy(proxySidecar(t, nil), "/tmp/praxis-policy.yaml") + if err != nil { + t.Fatalf("ConvertWithPolicy: %v", err) + } + if pol.Document != nil { + t.Error("expected no policy document") + } + for _, ch := range res.Config.FilterChains { + for _, f := range ch.Filters { + if f.Type == "policy" { + t.Error("no policy filter should be emitted when nothing is enforceable") + } + } + } +} + +// Two jwt-validation entries in one stage still yield a single policy filter: +// one document carries both, so a second entry would re-run the same engine. +func TestConvertWithPolicy_SinglePolicyFilterPerChain(t *testing.T) { + res, _, err := ConvertWithPolicy(proxySidecar(t, func(c *config.Config) { + c.Pipeline.Inbound.Plugins = []config.PluginEntry{keycloakJWT(t), keycloakJWT(t)} + }), "/tmp/praxis-policy.yaml") + if err != nil { + t.Fatalf("ConvertWithPolicy: %v", err) + } + n := 0 + for _, f := range res.Config.FilterChains[0].Filters { + if f.Type == "policy" { + n++ + } + } + if n != 1 { + t.Errorf("policy filters = %d, want 1", n) + } +} + +func TestRenderPolicyResult_ParsesAsYAML(t *testing.T) { + pol, err := BuildPolicy(proxySidecar(t, func(c *config.Config) { + c.Pipeline.Inbound.Plugins = []config.PluginEntry{keycloakJWT(t)} + })) + if err != nil { + t.Fatalf("BuildPolicy: %v", err) + } + out, err := RenderPolicyResult(pol, "/tmp/praxis-config.yaml") + if err != nil { + t.Fatalf("RenderPolicyResult: %v", err) + } + var probe struct { + Plugins []struct { + Name string `yaml:"name"` + Kind string `yaml:"kind"` + Hooks []string `yaml:"hooks"` + Config struct { + TrustedIssuers []struct { + Issuer string `yaml:"issuer"` + Audiences []string `yaml:"audiences"` + Algorithms []string `yaml:"algorithms"` + DecodingKey struct { + Kind string `yaml:"kind"` + URL string `yaml:"url"` + } `yaml:"decoding_key"` + } `yaml:"trusted_issuers"` + } `yaml:"config"` + } `yaml:"plugins"` + } + if err := yaml.Unmarshal(out, &probe); err != nil { + t.Fatalf("rendered policy does not parse: %v\n%s", err, out) + } + if len(probe.Plugins) != 1 { + t.Fatalf("plugins = %d, want 1", len(probe.Plugins)) + } + if probe.Plugins[0].Kind != policyPluginKindJWT { + t.Errorf("kind = %q", probe.Plugins[0].Kind) + } + if len(probe.Plugins[0].Config.TrustedIssuers) != 1 { + t.Fatal("expected one trusted issuer to survive the round trip") + } + if probe.Plugins[0].Config.TrustedIssuers[0].DecodingKey.Kind != "jwks_url" { + t.Error("expected the jwks_url decoding key to survive the round trip") + } +} + +func TestRenderPolicyResult_NilDocument_IsError(t *testing.T) { + if _, err := RenderPolicyResult(&PolicyResult{}, "x.yaml"); err == nil { + t.Fatal("expected an error rendering a nil document") + } +} + +// TestGeneratedPolicy_ValidatesWithPraxis runs the real policy-engine Praxis +// binary against the generated pair. This is what actually pins the goal: the +// policy engine parses the document at filter-construction time and fails the +// server start on a malformed policy, which no Go-side assertion can stand in +// for. +// +// Requires a Praxis binary built with --features policy-engine. Skipped +// otherwise, and skipped when the available binary is a default build (detected +// by it rejecting the policy filter as unknown). +func TestGeneratedPolicy_ValidatesWithPraxis(t *testing.T) { + bin := findPraxisBinary(t) + if bin == "" { + t.Skip("no praxis binary found; set PRAXIS_BIN to enable this test") + } + if !praxisHasPolicyEngine(t, bin) { + t.Skip("praxis binary lacks the policy-engine feature; rebuild with --features policy-engine") + } + + cases := []struct { + name string + plugins []config.PluginEntry + }{ + { + name: "keycloak issuer with explicit audience", + plugins: []config.PluginEntry{keycloakJWT(t)}, + }, + { + name: "https jwks, multiple audiences", + plugins: []config.PluginEntry{jwtPlugin(t, map[string]any{ + "issuer": "https://idp.example.com/realms/r", + "jwks_url": "https://idp.example.com/realms/r/protocol/openid-connect/certs", + "audience": "primary", + "allowed_audiences": []string{"extra"}, + })}, + }, + { + name: "no audience (aud validation disabled)", + plugins: []config.PluginEntry{jwtPlugin(t, map[string]any{ + "issuer": "https://idp.example.com/realms/r", + "audience_file": "/shared/client-id.txt", + })}, + }, + { + name: "bypass paths declared", + plugins: []config.PluginEntry{jwtPlugin(t, map[string]any{ + "issuer": "https://idp.example.com/realms/r", + "audience": "svc", + "bypass_paths": []string{"/healthz", "/.well-known/*"}, + })}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg := &config.Config{ + Mode: config.ModeProxySidecar, + Listener: config.ListenerConfig{ + Roles: []string{config.RoleReverse}, + ReverseProxyBackend: "http://127.0.0.1:8001", + }, + // Loopback so the admin bind needs no insecure override. + Stats: config.StatsConfig{StatsAddress: "127.0.0.1:19093"}, + } + cfg.Pipeline.Inbound.Plugins = tc.plugins + config.ApplyPreset(cfg) + + dir := t.TempDir() + policyPath := filepath.Join(dir, "praxis-policy.yaml") + cfgPath := filepath.Join(dir, "praxis-config.yaml") + + res, pol, err := ConvertWithPolicy(cfg, policyPath) + if err != nil { + t.Fatalf("ConvertWithPolicy: %v", err) + } + if pol.Document == nil { + t.Fatal("expected a policy document") + } + policyData, err := RenderPolicyResult(pol, cfgPath) + if err != nil { + t.Fatalf("RenderPolicyResult: %v", err) + } + if err := os.WriteFile(policyPath, policyData, 0o600); err != nil { + t.Fatalf("write policy: %v", err) + } + cfgData, err := RenderResult(res, cfgPath) + if err != nil { + t.Fatalf("RenderResult: %v", err) + } + if err := os.WriteFile(cfgPath, cfgData, 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + out, err := exec.Command(bin, "-t", "-c", cfgPath).CombinedOutput() + if err != nil { + t.Errorf("praxis rejected the generated pair: %v\n--- praxis ---\n%s\n--- config ---\n%s\n--- policy ---\n%s", + err, out, cfgData, policyData) + } + }) + } +} + +// praxisHasPolicyEngine reports whether the binary was built with the +// policy-engine feature, by checking whether it recognizes the filter name. +func praxisHasPolicyEngine(t *testing.T, bin string) bool { + t.Helper() + dir := t.TempDir() + probe := filepath.Join(dir, "probe.yaml") + // A policy filter pointing at a missing file: a default build reports + // "unknown filter type", a policy-engine build gets far enough to complain + // about the unreadable config_path. + const yamlBody = `listeners: + - name: l + address: "127.0.0.1:18099" + filter_chains: [c] +filter_chains: + - name: c + filters: + - filter: policy + config_path: /nonexistent/policy.yaml + - filter: static_response + status: 200 +` + if err := os.WriteFile(probe, []byte(yamlBody), 0o600); err != nil { + t.Fatalf("write probe: %v", err) + } + out, _ := exec.Command(bin, "-t", "-c", probe).CombinedOutput() + return !strings.Contains(string(out), "unknown filter type") +} diff --git a/authbridge/authlib/praxis/praxis.go b/authbridge/authlib/praxis/praxis.go new file mode 100644 index 000000000..04ddb54fc --- /dev/null +++ b/authbridge/authlib/praxis/praxis.go @@ -0,0 +1,934 @@ +// Package praxis converts an AuthBridge [config.Config] into a Praxis +// proxy configuration (https://github.com/praxis-proxy/praxis). +// +// The two proxies solve overlapping problems with different vocabularies. +// AuthBridge describes a sidecar as a mode plus a set of listener addresses, +// and hangs behavior off an ordered plugin pipeline (inbound and outbound). +// Praxis describes a proxy as a list of bound listeners, each referencing +// named filter chains, where the chain terminates in a router that selects a +// cluster and a load_balancer that resolves that cluster to endpoints. +// +// The mapping this package implements: +// +// AuthBridge Praxis +// ───────────────────────────────────── ────────────────────────────────── +// listener.reverse_proxy_addr listeners[name=inbound].address +// listener.reverse_proxy_backend (URL) load_balancer cluster endpoint +// listener.transparent_inbound_addr listeners[name=inbound].address +// listener.forward_proxy_addr listeners[name=outbound].address +// stats.address (host only) admin.address (host + port 9091) +// /healthz, /readyz (:9091) admin /healthy, /ready +// mtls.mode=permissive tls.client_cert_mode=request +// mtls.mode=strict tls.client_cert_mode=require +// spiffe mirror files tls.certificates / tls.client_ca +// pipeline.inbound.plugins filter chain (see below) +// pipeline.outbound.plugins filter chain (see below) +// +// # Plugin translation and its limits +// +// Praxis's default cargo build (`cargo run -p praxis-proxy`, features +// `default = []`) compiles in no JWT-validation or RFC 8693 token-exchange +// filter. Praxis does ship a `policy` filter whose description covers exactly +// that ground — "multi-source JWT identity, APL route policy, RFC 8693 token +// exchange" — but it sits behind the off-by-default `policy-engine` cargo +// feature and is driven by a separate operator-supplied policy document +// rather than by fields inline in the proxy config. A default-feature binary +// rejects it outright: +// +// invalid configuration: unknown filter type: 'policy' +// +// Praxis also validates each filter's fields strictly (unknown field on a +// filter entry is a hard error), so there is no way to smuggle AuthBridge's +// per-plugin config through as extra keys. +// +// This converter therefore translates faithfully rather than optimistically. +// Plugins with a real structural counterpart are emitted as Praxis filters +// (see [pluginFilters]); plugins whose enforcement has no default-build +// equivalent are recorded as comments in the generated YAML and returned in +// [Result.Unmapped]. Callers that need the auth enforcement itself should +// build Praxis with `--features policy-engine` and supply a policy document; +// see [Result.Unmapped] for the list to carry across. +// +// The output is designed so that +// +// cargo run -p praxis-proxy -- -c /tmp/praxis-config.yaml +// +// parses it, passes Praxis's own validation (`praxis -t`), and proxies +// traffic to the same backend AuthBridge would have forwarded to. +package praxis + +import ( + "fmt" + "net" + "net/url" + "sort" + "strconv" + "strings" + + "github.com/rossoctl/cortex/authbridge/authlib/config" + "gopkg.in/yaml.v3" +) + +// Well-known names used in the generated config. Praxis requires listener, +// chain, and cluster names to be unique and to cross-reference exactly, so +// they are constants rather than inline literals. +const ( + // ListenerInbound is the listener name for AuthBridge's inbound + // (reverse-proxy) side. + ListenerInbound = "inbound" + // ListenerOutbound is the listener name for AuthBridge's outbound + // (forward-proxy) side. + ListenerOutbound = "outbound" + + // ChainInbound / ChainOutbound are the filter-chain names referenced by + // the corresponding listeners. + ChainInbound = "inbound" + ChainOutbound = "outbound" + + // ClusterInbound is the cluster the inbound router selects: the + // application AuthBridge sits in front of. + ClusterInbound = "agent_backend" + // ClusterOutbound is the cluster the outbound router selects when a + // concrete egress destination is known. + ClusterOutbound = "egress_default" + + // DirectionHeader is the header Envoy injects on AuthBridge's inbound + // path and that AuthBridge strips before forwarding to the app. The + // generated inbound chain strips it too, preserving that contract. + DirectionHeader = "x-authbridge-direction" +) + +// AdminPort is the port the generated Praxis admin endpoint binds by default. +// +// 9091 is AuthBridge's health-server port (hardcoded as ":9091" in each +// binary's StartHealthServer call), serving /healthz and /readyz. Praxis's +// admin endpoint is the closest counterpart: its /ready is the same readiness +// probe as /readyz, and /healthy the same liveness probe as /healthz — so a +// probe already pointed at 9091 keeps working against the generated proxy. +// +// The alternative was AuthBridge's stats port, 9093 (/stats, /config, +// /reload/status). Praxis's admin endpoint does also carry /metrics, which is +// stats-shaped, but the readiness/liveness pair is what an orchestrator wires +// up, and moving that would silently break existing probes. +const AdminPort = 9091 + +// defaultAdminAddr is the fallback admin bind. Loopback because Praxis rejects +// a non-loopback admin bind unless insecure_options.allow_public_admin is set. +const defaultAdminAddr = "127.0.0.1:9091" + +// Config is the root Praxis configuration document. +// +// Field order follows the YAML that Praxis's own examples use (listeners +// first, then chains, then the optional top-level blocks) rather than the +// alphabetical order of the Rust struct, because the generated file is meant +// to be read by operators. Praxis's root struct sets serde +// deny_unknown_fields, so every key emitted here must exist upstream; the +// omitempty tags keep optional blocks out of the document entirely when +// they carry nothing. +type Config struct { + Listeners []Listener `yaml:"listeners"` + FilterChains []FilterChain `yaml:"filter_chains,omitempty"` + Admin *Admin `yaml:"admin,omitempty"` + // ShutdownTimeoutSecs mirrors Praxis's graceful-drain window. Emitted + // only when non-zero so the document stays close to Praxis's defaults. + ShutdownTimeoutSecs int `yaml:"shutdown_timeout_secs,omitempty"` + InsecureOptions *InsecureOptions `yaml:"insecure_options,omitempty"` +} + +// Listener is one bound socket. Name and Address are the only required +// fields upstream; the rest are omitted when empty so Praxis applies its +// own defaults (protocol http, no TLS, unlimited connections). +type Listener struct { + Name string `yaml:"name"` + Address string `yaml:"address"` + FilterChains []string `yaml:"filter_chains,omitempty"` + TLS *TLS `yaml:"tls,omitempty"` +} + +// TLS is a listener's TLS block. AuthBridge's mTLS is symmetric and +// SPIRE-backed, so Certificates points at the SVID mirror files and ClientCA +// at the trust bundle the SPIFFE provider keeps fresh. +type TLS struct { + Certificates []Certificate `yaml:"certificates,omitempty"` + ClientCA *ClientCA `yaml:"client_ca,omitempty"` + // ClientCertMode is "request" (accept both TLS and plaintext-authenticated + // peers) or "require" (reject peers without a valid cert). Praxis rejects + // request/require without a client_ca, so the two travel together. + ClientCertMode string `yaml:"client_cert_mode,omitempty"` +} + +// Certificate is one serving keypair. +// +// Comments are emitted above cert_path so the generated file explains where +// the referenced files come from — see [Certificate.MarshalYAML]. +type Certificate struct { + CertPath string `yaml:"cert_path"` + KeyPath string `yaml:"key_path"` + // Comments are rendered as YAML comments immediately above cert_path. + // Not a Praxis field — excluded from the marshalled output. + Comments []string `yaml:"-"` +} + +// MarshalYAML emits the keypair with Comments rendered above cert_path. +// +// Praxis reads certificates from files, but AuthBridge's SVID material comes +// from the in-process SPIFFE provider; the paths here are that provider's disk +// mirror. Whoever reads the generated config needs to know that, because the +// files do not exist unless something is writing them. +func (c Certificate) MarshalYAML() (any, error) { + node := &yaml.Node{Kind: yaml.MappingNode} + certKey := &yaml.Node{Kind: yaml.ScalarNode, Value: "cert_path"} + if len(c.Comments) > 0 { + // Join first, then wrap once: the comment lines are prose fragments of + // one paragraph, so wrapping each independently would leave short + // orphan lines wherever a fragment ended mid-sentence. + certKey.HeadComment = strings.Join( + wrapComment(strings.Join(c.Comments, " "), 72), "\n") + } + node.Content = append(node.Content, + certKey, &yaml.Node{Kind: yaml.ScalarNode, Value: c.CertPath}, + &yaml.Node{Kind: yaml.ScalarNode, Value: "key_path"}, + &yaml.Node{Kind: yaml.ScalarNode, Value: c.KeyPath}, + ) + return node, nil +} + +// ClientCA is the CA bundle peer certificates are verified against. +type ClientCA struct { + CAPath string `yaml:"ca_path"` +} + +// FilterChain is a named, reusable ordered list of filters. +type FilterChain struct { + Name string `yaml:"name"` + Filters []Filter `yaml:"filters,omitempty"` +} + +// Filter is one entry in a chain. +// +// Praxis filter entries are *flat*: the filter's own typed fields sit +// directly alongside the structural keys (`filter`, `name`, `conditions`, +// `failure_mode`) rather than under a nested `config:` wrapper. Praxis then +// deserializes the per-filter fields into that filter's typed struct with +// unknown-field rejection. Fields is therefore marshalled inline via +// [Filter.MarshalYAML] rather than as a nested mapping — emitting a `config:` +// block would be rejected as an unknown field. +type Filter struct { + // Type is the registered filter name, e.g. "router", "load_balancer". + Type string + // Comments are emitted as YAML comments immediately above the entry. + // Used to record what an AuthBridge plugin meant when it has no + // default-build Praxis counterpart. + Comments []string + // Fields are the filter's own typed fields, flattened into the entry. + // Ordered so the generated YAML is deterministic. + Fields []Field +} + +// Field is one key/value pair of a filter's typed configuration. +type Field struct { + Key string + Value any +} + +// Route is a single router route. PathPrefix plus Cluster is the form this +// converter emits; Host is set when the AuthBridge route keyed on a host +// pattern that Praxis can express literally. +type Route struct { + PathPrefix string `yaml:"path_prefix,omitempty"` + Host string `yaml:"host,omitempty"` + Cluster string `yaml:"cluster"` +} + +// Cluster is a named set of upstream endpoints, declared inline on the +// load_balancer filter. +type Cluster struct { + Name string `yaml:"name"` + Endpoints []string `yaml:"endpoints"` +} + +// HeaderPair is a name/value header entry for the headers filter. +type HeaderPair struct { + Name string `yaml:"name"` + Value string `yaml:"value"` +} + +// Admin is the admin listener serving /healthy, /ready, and /metrics. +type Admin struct { + Address string `yaml:"address"` + Verbose bool `yaml:"verbose,omitempty"` +} + +// InsecureOptions carries Praxis's security overrides. Only emitted when the +// conversion actually needs one; see [convertInsecureOptions]. +type InsecureOptions struct { + // AllowPublicAdmin lets the admin endpoint bind a non-loopback address. + // AuthBridge deliberately binds its stats server on all interfaces so the + // Rossoctl UI can reach it, which Praxis treats as a validation error + // without this flag. + AllowPublicAdmin bool `yaml:"allow_public_admin,omitempty"` +} + +// Result is the outcome of a conversion: the Praxis config plus an account +// of what could not be represented. +type Result struct { + // Config is the generated Praxis configuration. + Config *Config + // Unmapped lists AuthBridge plugins that have no counterpart in a + // default-feature Praxis build, in pipeline order. Each entry is + // human-readable, e.g. + // `inbound plugin "jwt-validation": no default-build Praxis filter + // performs JWT validation; use the policy filter (requires the + // policy-engine cargo feature) with an equivalent policy document`. + // + // Empty means every plugin was either translated or is a no-op for + // Praxis. Non-empty is not an error: the generated config is still valid + // and still proxies traffic, it just does not enforce those plugins. + Unmapped []string + // Warnings records structural facts the operator should know about the + // generated config that are not per-plugin, e.g. an AuthBridge mode whose + // listeners live outside AuthBridge itself. + Warnings []string +} + +// Options tunes a conversion. +type Options struct { + // PolicyPath, when non-empty, is the path the generated Praxis policy + // document will live at. Setting it makes [Convert] emit a `policy` filter + // referencing that path in each pipeline stage whose AuthBridge plugins the + // policy enforces, instead of an inert UNMAPPED marker. + // + // The path is written into the generated proxy config as the filter's + // `config_path`, so it must be the path Praxis will see at runtime — an + // absolute path, resolvable by the Praxis process. Convert does not create + // or read the file; the caller writes it (see [BuildPolicy] and + // [RenderPolicyResult]). + // + // Only set this when the policy document is actually being generated AND + // Praxis is built with the `policy-engine` cargo feature. A default-feature + // build rejects the `policy` filter outright ("unknown filter type: + // 'policy'"), so emitting it unconditionally would break the default path. + PolicyPath string + + // PolicyEnforces names the AuthBridge plugins the policy document at + // PolicyPath enforces (e.g. "jwt-validation"), as reported by + // [PolicyResult.Enforced]. Those plugins are then translated as the + // `policy` filter rather than reported unmapped. Ignored when PolicyPath + // is empty. + PolicyEnforces []string +} + +// Convert builds a Praxis configuration from an AuthBridge config. +// +// The AuthBridge config should already have had [config.ApplyPreset] applied +// (as the binaries do at boot), since Convert reads the resolved listener +// addresses rather than re-deriving mode defaults. A nil cfg is an error. +// +// Convert does not fail on plugins it cannot represent — those are reported +// via [Result.Unmapped] so the caller can decide. It fails only when the +// AuthBridge config cannot yield a usable Praxis document at all (e.g. no +// listener address to bind). +// +// Passing a nil opts is equivalent to the zero Options: no policy document, so +// auth plugins are reported unmapped. See [ConvertWithPolicy] for the common +// path of generating the policy and the proxy config together. +func Convert(cfg *config.Config, opts *Options) (*Result, error) { + if cfg == nil { + return nil, fmt.Errorf("praxis: nil AuthBridge config") + } + if opts == nil { + opts = &Options{} + } + + res := &Result{Config: &Config{}} + + switch cfg.Mode { + case config.ModeProxySidecar, "": + // The shape this converter targets: AuthBridge owns the listeners. + case config.ModeEnvoySidecar: + res.Warnings = append(res.Warnings, + "mode envoy-sidecar: AuthBridge runs as an Envoy ext_proc callout and binds no "+ + "data-plane listener, so there is no listener address to translate. Praxis "+ + "replaces Envoy itself rather than the callout; the generated config uses the "+ + "proxy-sidecar listener fields if present.") + case config.ModeWaypoint: + res.Warnings = append(res.Warnings, + "mode waypoint: AuthBridge runs as an ext_authz callout behind an Istio waypoint, "+ + "so its inbound listener is the waypoint's, not its own. Only the forward-proxy "+ + "address, if set, is translated.") + default: + return nil, fmt.Errorf("praxis: unknown AuthBridge mode %q", cfg.Mode) + } + + roles := cfg.Listener.ActiveRoles() + tls, tlsWarnings := convertMTLS(cfg) + res.Warnings = append(res.Warnings, tlsWarnings...) + + // ── Inbound ────────────────────────────────────────────────────────── + // Two inbound shapes map to one Praxis listener with different backends. + // reverse-proxy interception has a fixed backend URL; transparent + // interception recovers the backend per connection via SO_ORIGINAL_DST, + // which Praxis has no equivalent for — so that becomes a warning and the + // listener is emitted only when a concrete backend is known. + if roles[config.RoleReverse] { + addr, backend, err := inboundAddrAndBackend(cfg, res) + if err != nil { + return nil, err + } + if addr != "" && backend != "" { + chain, unmapped := pluginFilters(cfg.Pipeline.Inbound.Plugins, directionInbound, opts) + // Strip the direction header before the app sees it, matching + // AuthBridge's inbound contract. + chain = append(chain, Filter{ + Type: "headers", + Comments: []string{ + "AuthBridge strips the Envoy-injected direction header before forwarding", + "to the application; preserve that contract here.", + }, + Fields: []Field{{Key: "request_remove", Value: []string{DirectionHeader}}}, + }) + chain = append(chain, routerAndLoadBalancer(ClusterInbound, []string{backend})...) + + res.Config.Listeners = append(res.Config.Listeners, Listener{ + Name: ListenerInbound, + Address: normalizeBindAddr(addr), + FilterChains: []string{ChainInbound}, + TLS: tls, + }) + res.Config.FilterChains = append(res.Config.FilterChains, FilterChain{ + Name: ChainInbound, + Filters: chain, + }) + res.Unmapped = append(res.Unmapped, unmapped...) + } + } + + // ── Outbound ───────────────────────────────────────────────────────── + // AuthBridge's outbound side is an HTTP *forward* proxy: the destination + // comes from each request (absolute-form URI or CONNECT), not from + // config. Praxis is a reverse proxy — its router selects a preconfigured + // cluster — so there is no faithful translation of "forward proxy" as + // such. What is translatable is per-host egress routing: when the + // outbound pipeline declares concrete destination hosts, they become + // router routes matched on Host. + if roles[config.RoleForward] && cfg.Listener.ForwardProxyAddr != "" { + chain, unmapped := pluginFilters(cfg.Pipeline.Outbound.Plugins, directionOutbound, opts) + res.Unmapped = append(res.Unmapped, unmapped...) + res.Warnings = append(res.Warnings, + "listener.forward_proxy_addr translated as a reverse-proxy listener: AuthBridge's "+ + "outbound side is an HTTP forward proxy that resolves each request's destination "+ + "at request time (absolute-form URI or CONNECT), which Praxis's router — which "+ + "selects a preconfigured cluster — cannot express. Egress destinations must be "+ + "declared as routes/clusters; the generated listener carries a static_response "+ + "placeholder until they are.") + + // With no known egress destination there is nothing for a router to + // select, and Praxis rejects a load_balancer whose cluster is never + // selected. Emit a terminal static_response so the listener is valid + // and its behavior is explicit rather than accidentally open. + chain = append(chain, Filter{ + Type: "static_response", + Comments: []string{ + "No egress destinations were derivable from the AuthBridge outbound", + "pipeline. Replace with router + load_balancer entries naming the", + "hosts this workload is allowed to reach.", + }, + Fields: []Field{ + {Key: "status", Value: 502}, + {Key: "body", Value: "praxis: no egress route configured for this destination\n"}, + }, + }) + + res.Config.Listeners = append(res.Config.Listeners, Listener{ + Name: ListenerOutbound, + Address: normalizeBindAddr(cfg.Listener.ForwardProxyAddr), + FilterChains: []string{ChainOutbound}, + TLS: tls, + }) + res.Config.FilterChains = append(res.Config.FilterChains, FilterChain{ + Name: ChainOutbound, + Filters: chain, + }) + } + + if len(res.Config.Listeners) == 0 { + return nil, fmt.Errorf( + "praxis: AuthBridge config yields no Praxis listener (mode %q, roles %v): "+ + "Praxis requires at least one listener", cfg.Mode, cfg.Listener.Roles) + } + + // ── Top-level blocks ───────────────────────────────────────────────── + res.Config.Admin, res.Config.InsecureOptions = convertAdmin(cfg) + if cfg.TLSBridge != nil && cfg.TLSBridge.Mode == "enabled" { + res.Warnings = append(res.Warnings, + "tls_bridge is enabled but has no Praxis counterpart: AuthBridge terminates the "+ + "agent's outbound TLS with a per-agent signing CA so the outbound pipeline sees "+ + "decrypted HTTPS. Praxis terminates TLS only for traffic addressed to its own "+ + "listeners and does not forge leaves for arbitrary upstream hosts.") + } + if cfg.Session.SessionEnabled() { + res.Warnings = append(res.Warnings, + "session tracking is enabled but has no Praxis counterpart: AuthBridge's in-memory "+ + "session store and its :9094 events API are AuthBridge-specific. Praxis exposes "+ + "request metrics on the admin endpoint (/metrics) and per-request records via "+ + "the access_log filter instead.") + } + + return res, nil +} + +// inboundAddrAndBackend resolves the inbound bind address and the single +// upstream endpoint the router should select, across AuthBridge's two +// inbound interception shapes. +func inboundAddrAndBackend(cfg *config.Config, res *Result) (addr, backend string, err error) { + if cfg.Listener.InboundTransparent() { + // SO_ORIGINAL_DST has no Praxis equivalent: Praxis routes to + // preconfigured clusters and never learns the port the client + // originally addressed. Report it rather than inventing a backend. + res.Warnings = append(res.Warnings, + "listener.inbound_interception=transparent has no Praxis counterpart: AuthBridge "+ + "recovers each connection's original destination via SO_ORIGINAL_DST and forwards "+ + "there over loopback. Praxis routes to preconfigured clusters, so no inbound "+ + "listener was generated. Set listener.reverse_proxy_backend to the application's "+ + "address to emit one.") + return "", "", nil + } + if cfg.Listener.ReverseProxyAddr == "" || cfg.Listener.ReverseProxyBackend == "" { + return "", "", nil + } + endpoint, err := endpointFromBackendURL(cfg.Listener.ReverseProxyBackend) + if err != nil { + return "", "", err + } + return cfg.Listener.ReverseProxyAddr, endpoint, nil +} + +// routerAndLoadBalancer emits the terminal pair every Praxis HTTP chain that +// proxies to an upstream needs. Praxis validates that a load_balancer is +// preceded by a cluster-selecting filter and that every cluster a router +// selects is defined on the load_balancer, so the two are always emitted +// together and share the cluster name. +func routerAndLoadBalancer(cluster string, endpoints []string) []Filter { + return []Filter{ + { + Type: "router", + Fields: []Field{{Key: "routes", Value: []Route{ + {PathPrefix: "/", Cluster: cluster}, + }}}, + }, + { + Type: "load_balancer", + Fields: []Field{{Key: "clusters", Value: []Cluster{ + {Name: cluster, Endpoints: endpoints}, + }}}, + }, + } +} + +// convertMTLS maps AuthBridge's single symmetric mTLS mode onto a Praxis +// listener TLS block, and reports when the referenced files will not exist. +// +// AuthBridge sources SVID material from the in-process SPIFFE provider and +// mirrors it to disk for external readers; Praxis reads certificates from +// files, so the mirror paths are the integration point. Two things therefore +// have to be true for the generated TLS block to work, and neither is implied +// by the mtls block alone: +// +// - A SPIFFE provider must be running (a top-level `spiffe:` block), or +// nothing writes the files at all. +// - Its file mirror must be on (`spiffe.mirror_files`, default true), or the +// provider keeps SVIDs in memory only and Praxis has nothing to read. +// +// A returned warning is not fatal: the Praxis config still validates (Praxis +// checks cert paths at listener startup, not at config parse), so the operator +// gets a document plus an explicit statement of what is missing. +func convertMTLS(cfg *config.Config) (*TLS, []string) { + if cfg.MTLS == nil { + return nil, nil + } + mode := "request" // permissive: ask for a cert, allow peers without one + if cfg.MTLS.ResolvedMode() == config.MTLSModeStrict { + mode = "require" // strict: reject peers without a valid cert + } + dir := "/opt" + if cfg.SPIFFE != nil && cfg.SPIFFE.MirrorDir != "" { + dir = cfg.SPIFFE.MirrorDir + } + certPath := dir + "/svid.pem" + keyPath := dir + "/svid_key.pem" + bundlePath := dir + "/svid_bundle.pem" + + var ( + warnings []string + comments []string + ) + switch { + case cfg.SPIFFE == nil: + // The case the weather-service example hits: mtls is set but the + // spiffe block is absent (or commented out), so no provider runs and + // no SVID files are ever written. + warnings = append(warnings, fmt.Sprintf( + "mtls is configured (mode: %s) but the config has no top-level `spiffe:` block, so no "+ + "SPIFFE provider runs and %s will NOT be generated — nothing writes it. The "+ + "generated Praxis listeners reference %s, %s, and %s, and Praxis will fail to bind "+ + "them at startup while those files are absent. Add a `spiffe:` block (the provider "+ + "mirrors the files on every rotation), or drop the `mtls:` block to generate "+ + "plaintext listeners.", + cfg.MTLS.ResolvedMode(), certPath, certPath, keyPath, bundlePath)) + comments = append(comments, + "WARNING: these files will NOT exist. The AuthBridge config sets `mtls:` but has", + "no top-level `spiffe:` block, so no SPIFFE provider runs and nothing writes", + certPath+". Praxis will fail to bind this listener until the files are present.", + "Add a `spiffe:` block to the AuthBridge config, or drop `mtls:` for plaintext.") + case cfg.SPIFFE.MirrorFiles != nil && !*cfg.SPIFFE.MirrorFiles: + // A provider runs but was explicitly told not to mirror, so the SVIDs + // stay in memory where Praxis cannot reach them. + warnings = append(warnings, fmt.Sprintf( + "mtls is configured but spiffe.mirror_files is explicitly false, so the SPIFFE "+ + "provider keeps SVIDs in memory and does not write %s. Praxis reads certificates "+ + "from files and has no way to consume the in-process source, so it will fail to "+ + "bind these listeners. Set spiffe.mirror_files: true (the default) to generate "+ + "the files.", certPath)) + comments = append(comments, + "WARNING: these files will NOT exist. spiffe.mirror_files is explicitly false, so", + "the SPIFFE provider keeps SVIDs in memory and never writes "+certPath+".", + "Set spiffe.mirror_files: true (the default) so Praxis can read them.") + default: + comments = append(comments, + "AuthBridge's in-process spiffe.Provider writes "+certPath+" (and", + "the key and trust bundle alongside it), refreshing them on every SVID", + "rotation. Praxis reads its certificates from files and cannot consume the", + "provider's in-memory source, so this disk mirror is the integration point.", + "The files exist only while that provider is running with mirroring enabled.") + } + + return &TLS{ + Certificates: []Certificate{{ + CertPath: certPath, + KeyPath: keyPath, + Comments: comments, + }}, + ClientCA: &ClientCA{CAPath: bundlePath}, + ClientCertMode: mode, + }, warnings +} + +// convertAdmin maps AuthBridge's stats server onto Praxis's admin endpoint, +// which serves /healthy, /ready, and /metrics — covering the health and +// stats servers AuthBridge runs separately. +// +// AuthBridge deliberately binds its stats address on all interfaces so the +// Rossoctl UI can scrape it. Praxis rejects a non-loopback admin bind unless +// insecure_options.allow_public_admin is set, so a public bind is carried +// across together with the flag that makes it valid rather than being +// silently rewritten to loopback (which would break the UI). +// The admin endpoint binds on [AdminPort] rather than carrying the stats port +// across, because /ready and /healthy correspond to AuthBridge's /readyz and +// /healthz on 9091, not to the stats endpoints on 9093. Only the HOST from +// stats.address is reused — that carries the operator's intent about +// reachability (loopback vs all interfaces), which the port does not. +func convertAdmin(cfg *config.Config) (*Admin, *InsecureOptions) { + addr := cfg.Stats.StatsAddress + if addr == "" { + return &Admin{Address: defaultAdminAddr}, nil + } + // Take the host from stats.address and pair it with the health port. + // A malformed stats.address falls back to the default rather than being + // propagated: it would fail Praxis's admin SocketAddr parse either way, + // and this keeps one clear failure instead of two. + host, _, err := net.SplitHostPort(normalizeBindAddr(addr)) + if err != nil { + return &Admin{Address: defaultAdminAddr}, nil + } + adminAddr := net.JoinHostPort(host, strconv.Itoa(AdminPort)) + if isLoopbackAddr(adminAddr) { + return &Admin{Address: adminAddr}, nil + } + // AuthBridge binds stats on all interfaces so the Rossoctl UI can scrape + // it; Praxis rejects a non-loopback admin bind without this override. + // Carried across with the flag that makes it valid rather than rewritten + // to loopback, which would silently break that reachability. + return &Admin{Address: adminAddr}, &InsecureOptions{AllowPublicAdmin: true} +} + +// endpointFromBackendURL turns AuthBridge's backend URL +// ("http://localhost:8001") into a Praxis endpoint ("localhost:8001"). +// +// Praxis endpoints are host:port authorities, not URLs. A missing port is +// filled from the scheme so the endpoint is always explicit. +func endpointFromBackendURL(raw string) (string, error) { + // A bare authority ("localhost:8001") is accepted as-is: url.Parse would + // read "localhost" as the scheme and leave no host. + if !strings.Contains(raw, "//") { + if _, _, err := net.SplitHostPort(raw); err == nil { + return raw, nil + } + } + u, err := url.Parse(raw) + if err != nil { + return "", fmt.Errorf("praxis: parsing listener.reverse_proxy_backend %q: %w", raw, err) + } + host := u.Hostname() + if host == "" { + return "", fmt.Errorf( + "praxis: listener.reverse_proxy_backend %q has no host", raw) + } + port := u.Port() + if port == "" { + switch u.Scheme { + case "https": + port = "443" + default: + port = "80" + } + } + return net.JoinHostPort(host, port), nil +} + +// normalizeBindAddr turns AuthBridge's shorthand bind addresses into the +// explicit host:port form Praxis requires. AuthBridge accepts ":8080" +// (all interfaces); Praxis parses its address as a socket address and needs +// a host part. +func normalizeBindAddr(addr string) string { + if strings.HasPrefix(addr, ":") { + return "0.0.0.0" + addr + } + return addr +} + +// isLoopbackAddr reports whether a host:port binds only the loopback +// interface, matching Praxis's admin-endpoint rule (127.0.0.1 or [::1]). +func isLoopbackAddr(addr string) bool { + host, _, err := net.SplitHostPort(addr) + if err != nil { + return false + } + if host == "localhost" { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +// direction labels a pipeline stage for diagnostics. +type direction string + +const ( + directionInbound direction = "inbound" + directionOutbound direction = "outbound" +) + +// pluginTranslation describes how one AuthBridge plugin maps to Praxis. +type pluginTranslation struct { + // filters, when non-nil, is called to produce the Praxis filters this + // plugin becomes. Nil means the plugin has no filter representation. + filters func() []Filter + // note explains the gap when filters is nil. Recorded in + // [Result.Unmapped] and as a comment in the generated YAML. + note string +} + +// translations maps AuthBridge plugin names to their Praxis translation. +// +// Only plugins whose *enforcement* survives the translation get filters. +// AuthBridge's auth plugins (jwt-validation, token-exchange) and its +// policy/guardrail plugins have no default-build Praxis filter: Praxis's +// `policy` filter covers that ground but requires the off-by-default +// policy-engine cargo feature plus a separate policy document, so a +// default-feature binary rejects it as an unknown filter type. Those are +// recorded rather than dropped silently, because emitting nothing for a +// deny-capable plugin turns a closed door into an open one. +var translations = map[string]pluginTranslation{ + "jwt-validation": { + note: "validates inbound JWTs (signature via JWKS, issuer, audience) and rejects with " + + "401. No default-build Praxis filter performs JWT validation; Praxis's `policy` " + + "filter does, but requires the policy-engine cargo feature and a policy document. " + + "Without it the generated listener does NOT authenticate inbound requests.", + }, + "token-exchange": { + note: "performs RFC 8693 token exchange per outbound route and injects the result as " + + "Authorization. Praxis's `policy` filter covers RFC 8693 delegation but requires " + + "the policy-engine cargo feature; `credential_injection` injects only static " + + "per-cluster credentials, not exchanged tokens.", + }, + "opa": {note: "evaluates OPA policy over the request. No default-build Praxis equivalent."}, + "ibac": {note: "aligns outbound tool calls against the recorded inbound user intent. Requires AuthBridge's session store; no Praxis equivalent."}, + "sparc": {note: "rewrites request bodies per policy. No default-build Praxis equivalent."}, + "context-guru": {note: "compacts LLM context in outbound inference bodies. No Praxis equivalent."}, + "token-budget": {note: "enforces a token budget across a session. Requires AuthBridge's session store; no Praxis equivalent."}, + "token-broker": {note: "brokers tokens via an external service. No default-build Praxis equivalent."}, + "cpex": {note: "routes hooks through the CPEX policy framework. No Praxis equivalent."}, + "spiffe-identity": {note: "surfaces the peer's SPIFFE identity to later plugins. Praxis's " + + "`peer_identity_trust` filter validates a downstream mTLS peer against a configured " + + "allowlist, which is related but not equivalent (it gates rather than annotates)."}, + + // Protocol parsers annotate the request for downstream plugins rather + // than enforcing anything. Praxis has structurally similar filters, but + // they promote fields to headers for routing rather than populating an + // AuthBridge pipeline context, so translating them would imply an + // equivalence that does not hold. Recorded as observations. + "mcp-parser": {note: "parses MCP JSON-RPC bodies and classifies action vs protocol mechanics for " + + "downstream guardrails. Praxis's `json_rpc` filter extracts JSON-RPC envelope metadata " + + "(method/id/kind) to headers for routing — similar parsing, different purpose; it feeds " + + "no guardrail. Left out so the generated config does not imply enforcement."}, + "a2a-parser": {note: "parses A2A message bodies for downstream guardrails. No Praxis equivalent."}, + "inference-parser": {note: "parses LLM inference bodies (OpenAI/Anthropic wire) for downstream plugins. No Praxis equivalent."}, +} + +// pluginFilters translates a pipeline stage's plugins into Praxis filters, +// returning the filters plus a note per plugin that could not be translated. +// +// Plugins disabled via on_error: off are skipped entirely — AuthBridge drops +// them from the pipeline, so they are not gaps in the translation. +func pluginFilters(plugins []config.PluginEntry, dir direction, opts *Options) ([]Filter, []string) { + var ( + filters []Filter + unmapped []string + ) + // Plugins the generated policy document enforces. Those become a single + // `policy` filter at the position of the first one, rather than an inert + // marker — the policy engine runs them all from one document. + enforced := map[string]bool{} + if opts != nil && opts.PolicyPath != "" { + for _, n := range opts.PolicyEnforces { + enforced[n] = true + } + } + policyEmitted := false + // A single access_log entry per chain gives the generated config the + // per-request visibility AuthBridge provides through its session events, + // and request_id supplies the correlation ID. + filters = append(filters, + Filter{Type: "request_id"}, + Filter{Type: "access_log"}, + ) + + for _, p := range plugins { + if p.OnError == "off" { + continue + } + name := p.Name + t, known := translations[name] + switch { + case enforced[name]: + // Enforced by the generated policy document. Emit the `policy` + // filter once per chain: one document carries every enforced + // plugin, so a second entry would re-run the same engine. + if policyEmitted { + continue + } + policyEmitted = true + filters = append(filters, policyFilter(opts.PolicyPath, opts.PolicyEnforces)) + case known && t.filters != nil: + filters = append(filters, t.filters()...) + case known: + unmapped = append(unmapped, fmt.Sprintf("%s plugin %q: %s", dir, name, t.note)) + filters = append(filters, Filter{ + Type: "headers", + Comments: []string{ + fmt.Sprintf("UNMAPPED AuthBridge plugin %q (%s):", name, dir), + wrapNote(t.note), + "This entry is inert — it records the gap; it does not enforce the plugin.", + }, + Fields: []Field{{Key: "request_add", Value: []HeaderPair{{ + Name: "x-authbridge-unmapped-" + name, + Value: "not-enforced", + }}}}, + }) + default: + unmapped = append(unmapped, fmt.Sprintf( + "%s plugin %q: unrecognized by the Praxis converter; no filter emitted", dir, name)) + } + } + return filters, unmapped +} + +// policyFilter builds the `policy` filter entry referencing the generated +// policy document. +// +// require_protocol_metadata is set to false deliberately. It defaults to true +// upstream and fail-closes when the chain carries no `mcp.method` metadata from +// a protocol classifier filter — that classifier ships in the separate +// `praxis-ai` package and is not in the chains this converter generates. With +// the default left on, every request would be rejected for missing metadata +// rather than being judged on its token. False selects the identity-only +// enforcement path, which is exactly what AuthBridge's jwt-validation does. +// The flag is only consulted when the policy declares entity routes, and the +// generated policy declares none, but it is set explicitly so the intent +// survives someone later adding routes to the document. +func policyFilter(path string, enforces []string) Filter { + comments := []string{ + "Praxis Policy Engine — enforces the AuthBridge plugins listed below.", + "REQUIRES Praxis built with the policy-engine cargo feature:", + " cargo run --features policy-engine -p praxis-proxy -- -c ", + "A default-feature build rejects this filter as an unknown filter type.", + } + if len(enforces) > 0 { + comments = append(comments, + "Enforcing: "+strings.Join(enforces, ", ")) + } + comments = append(comments, + "require_protocol_metadata: false selects identity-only enforcement; the", + "protocol classifier filter it would otherwise require ships in praxis-ai.") + + return Filter{ + Type: "policy", + Comments: comments, + Fields: []Field{ + {Key: "config_path", Value: path}, + {Key: "require_protocol_metadata", Value: false}, + }, + } +} + +// ConvertWithPolicy builds both documents together: the Praxis policy document +// enforcing what it can of the AuthBridge pipeline, and a proxy config whose +// `policy` filter references that document at policyPath. +// +// policyPath is the path Praxis will load the policy from at runtime; the +// caller is responsible for writing the rendered policy there. When the +// AuthBridge config declares nothing the policy engine can enforce, the +// returned PolicyResult has a nil Document and the proxy config carries no +// `policy` filter — so the caller should skip writing a policy file entirely. +// +// The returned Result carries the policy's fidelity warnings alongside its own, +// so a single caller-side loop surfaces every caveat about the generated pair. +func ConvertWithPolicy(cfg *config.Config, policyPath string) (*Result, *PolicyResult, error) { + pol, err := BuildPolicy(cfg) + if err != nil { + return nil, nil, err + } + opts := &Options{} + if pol.Document != nil { + opts.PolicyPath = policyPath + opts.PolicyEnforces = pol.Enforced + } + res, err := Convert(cfg, opts) + if err != nil { + return nil, nil, err + } + res.Warnings = append(res.Warnings, pol.Warnings...) + return res, pol, nil +} + +// wrapNote collapses a note to a single comment line, trimming interior +// whitespace so the emitted YAML comment stays on one line. +func wrapNote(s string) string { + return strings.Join(strings.Fields(s), " ") +} + +// sortedPluginNames returns the plugin names this converter knows about, for +// diagnostics and tests. +func sortedPluginNames() []string { + out := make([]string, 0, len(translations)) + for k := range translations { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// KnownPlugins returns the AuthBridge plugin names the converter recognizes, +// sorted. A plugin absent from this list converts to a generic "unrecognized" +// entry in [Result.Unmapped] rather than being silently ignored. +func KnownPlugins() []string { return sortedPluginNames() } diff --git a/authbridge/authlib/praxis/praxis_test.go b/authbridge/authlib/praxis/praxis_test.go new file mode 100644 index 000000000..5d30e7fd7 --- /dev/null +++ b/authbridge/authlib/praxis/praxis_test.go @@ -0,0 +1,874 @@ +package praxis + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/rossoctl/cortex/authbridge/authlib/config" + "gopkg.in/yaml.v3" +) + +// proxySidecar builds a minimal proxy-sidecar config with presets applied, the +// way the binaries do at boot. +func proxySidecar(t *testing.T, mutate func(*config.Config)) *config.Config { + t.Helper() + cfg := &config.Config{ + Mode: config.ModeProxySidecar, + Listener: config.ListenerConfig{ + ReverseProxyBackend: "http://localhost:8001", + }, + } + if mutate != nil { + mutate(cfg) + } + config.ApplyPreset(cfg) + if err := config.Validate(cfg); err != nil { + t.Fatalf("fixture config is invalid: %v", err) + } + return cfg +} + +func TestConvert_NilConfig(t *testing.T) { + if _, err := Convert(nil, nil); err == nil { + t.Fatal("expected an error for a nil config") + } +} + +func TestConvert_UnknownMode(t *testing.T) { + if _, err := Convert(&config.Config{Mode: "bogus"}, nil); err == nil { + t.Fatal("expected an error for an unknown mode") + } +} + +// The default proxy-sidecar shape runs both roles, so it should yield an +// inbound and an outbound listener, each with its own chain. +func TestConvert_BothRoles(t *testing.T) { + res, err := Convert(proxySidecar(t, nil), nil) + if err != nil { + t.Fatalf("Convert: %v", err) + } + if got, want := len(res.Config.Listeners), 2; got != want { + t.Fatalf("listeners = %d, want %d", got, want) + } + names := []string{res.Config.Listeners[0].Name, res.Config.Listeners[1].Name} + if names[0] != ListenerInbound || names[1] != ListenerOutbound { + t.Errorf("listener names = %v, want [%s %s]", names, ListenerInbound, ListenerOutbound) + } + // ":8080" must become an explicit host:port; Praxis parses its address as + // a socket address and rejects a bare ":port". + if got, want := res.Config.Listeners[0].Address, "0.0.0.0:8080"; got != want { + t.Errorf("inbound address = %q, want %q", got, want) + } + if len(res.Config.FilterChains) != 2 { + t.Fatalf("filter chains = %d, want 2", len(res.Config.FilterChains)) + } +} + +// A forward-only deployment has no application backend and must not emit an +// inbound listener. +func TestConvert_ForwardOnly(t *testing.T) { + cfg := &config.Config{ + Mode: config.ModeProxySidecar, + Listener: config.ListenerConfig{Roles: []string{config.RoleForward}}, + } + config.ApplyPreset(cfg) + res, err := Convert(cfg, nil) + if err != nil { + t.Fatalf("Convert: %v", err) + } + if len(res.Config.Listeners) != 1 { + t.Fatalf("listeners = %d, want 1", len(res.Config.Listeners)) + } + if res.Config.Listeners[0].Name != ListenerOutbound { + t.Errorf("listener = %q, want %q", res.Config.Listeners[0].Name, ListenerOutbound) + } +} + +// A reverse-only deployment emits just the inbound listener, routed at the +// application backend. +func TestConvert_ReverseOnly(t *testing.T) { + cfg := proxySidecar(t, func(c *config.Config) { + c.Listener.Roles = []string{config.RoleReverse} + }) + res, err := Convert(cfg, nil) + if err != nil { + t.Fatalf("Convert: %v", err) + } + if len(res.Config.Listeners) != 1 { + t.Fatalf("listeners = %d, want 1", len(res.Config.Listeners)) + } + lb := lastFilter(t, res.Config.FilterChains[0], "load_balancer") + clusters, ok := lb.Fields[0].Value.([]Cluster) + if !ok { + t.Fatalf("load_balancer clusters field has type %T", lb.Fields[0].Value) + } + if got, want := clusters[0].Endpoints[0], "localhost:8001"; got != want { + t.Errorf("endpoint = %q, want %q", got, want) + } +} + +// Praxis requires that every cluster a router selects is defined on the +// load_balancer in the same chain. Guard the invariant directly, since a +// mismatch is a hard validation error at Praxis startup. +func TestConvert_RouterClusterIsDefined(t *testing.T) { + res, err := Convert(proxySidecar(t, func(c *config.Config) { + c.Listener.Roles = []string{config.RoleReverse} + }), nil) + if err != nil { + t.Fatalf("Convert: %v", err) + } + chain := res.Config.FilterChains[0] + router := lastFilter(t, chain, "router") + routes, ok := router.Fields[0].Value.([]Route) + if !ok { + t.Fatalf("router routes field has type %T", router.Fields[0].Value) + } + lb := lastFilter(t, chain, "load_balancer") + clusters := lb.Fields[0].Value.([]Cluster) + + defined := map[string]bool{} + for _, c := range clusters { + defined[c.Name] = true + } + for _, r := range routes { + if !defined[r.Cluster] { + t.Errorf("router selects cluster %q which no load_balancer defines", r.Cluster) + } + } +} + +// Praxis rejects a load_balancer that is not preceded by a cluster-selecting +// filter, so router must come before load_balancer in the emitted order. +func TestConvert_RouterPrecedesLoadBalancer(t *testing.T) { + res, err := Convert(proxySidecar(t, func(c *config.Config) { + c.Listener.Roles = []string{config.RoleReverse} + }), nil) + if err != nil { + t.Fatalf("Convert: %v", err) + } + var routerIdx, lbIdx = -1, -1 + for i, f := range res.Config.FilterChains[0].Filters { + switch f.Type { + case "router": + routerIdx = i + case "load_balancer": + lbIdx = i + } + } + if routerIdx == -1 || lbIdx == -1 { + t.Fatalf("expected both router and load_balancer, got router=%d lb=%d", routerIdx, lbIdx) + } + if routerIdx > lbIdx { + t.Errorf("router at %d must precede load_balancer at %d", routerIdx, lbIdx) + } +} + +func TestConvert_MTLSModes(t *testing.T) { + for _, tc := range []struct { + name string + mode config.MTLSMode + want string + }{ + {"permissive maps to request", config.MTLSModePermissive, "request"}, + {"empty defaults to permissive", "", "request"}, + {"strict maps to require", config.MTLSModeStrict, "require"}, + } { + t.Run(tc.name, func(t *testing.T) { + res, err := Convert(proxySidecar(t, func(c *config.Config) { + c.MTLS = &config.MTLSConfig{Mode: tc.mode} + }), nil) + if err != nil { + t.Fatalf("Convert: %v", err) + } + tls := res.Config.Listeners[0].TLS + if tls == nil { + t.Fatal("expected a TLS block") + } + if tls.ClientCertMode != tc.want { + t.Errorf("client_cert_mode = %q, want %q", tls.ClientCertMode, tc.want) + } + // Praxis rejects request/require without a client_ca. + if tls.ClientCA == nil || tls.ClientCA.CAPath == "" { + t.Error("client_cert_mode set without a client_ca; Praxis rejects this") + } + if len(tls.Certificates) == 0 { + t.Error("expected a serving certificate") + } + }) + } +} + +// No mtls block means today's plaintext behavior, so no TLS should be emitted. +func TestConvert_NoMTLS_NoTLSBlock(t *testing.T) { + res, err := Convert(proxySidecar(t, nil), nil) + if err != nil { + t.Fatalf("Convert: %v", err) + } + if res.Config.Listeners[0].TLS != nil { + t.Error("expected no TLS block when mtls is absent") + } +} + +// The SPIFFE mirror directory is where Praxis reads SVID material from, so a +// non-default mirror_dir must be reflected in the cert paths. +func TestConvert_MTLSUsesSPIFFEMirrorDir(t *testing.T) { + res, err := Convert(proxySidecar(t, func(c *config.Config) { + c.MTLS = &config.MTLSConfig{Mode: config.MTLSModeStrict} + c.SPIFFE = &config.SPIFFEConfig{MirrorDir: "/var/run/svid"} + }), nil) + if err != nil { + t.Fatalf("Convert: %v", err) + } + tls := res.Config.Listeners[0].TLS + if got, want := tls.Certificates[0].CertPath, "/var/run/svid/svid.pem"; got != want { + t.Errorf("cert_path = %q, want %q", got, want) + } + if got, want := tls.ClientCA.CAPath, "/var/run/svid/svid_bundle.pem"; got != want { + t.Errorf("ca_path = %q, want %q", got, want) + } +} + +// certComment returns the comment block attached to the first listener's +// serving certificate. +func certComment(t *testing.T, res *Result) string { + t.Helper() + tls := res.Config.Listeners[0].TLS + if tls == nil || len(tls.Certificates) == 0 { + t.Fatal("expected a TLS block with a certificate") + } + return strings.Join(tls.Certificates[0].Comments, " ") +} + +// The generated config must say where the referenced SVID files come from: +// they are not static assets, they are a running provider's disk mirror. +func TestConvert_MTLS_ExplainsSPIFFEProviderWritesCert(t *testing.T) { + res, err := Convert(proxySidecar(t, func(c *config.Config) { + c.MTLS = &config.MTLSConfig{Mode: config.MTLSModeStrict} + c.SPIFFE = &config.SPIFFEConfig{Socket: "unix:///x.sock"} + }), nil) + if err != nil { + t.Fatalf("Convert: %v", err) + } + comment := certComment(t, res) + if !strings.Contains(comment, "spiffe.Provider") { + t.Errorf("comment should name spiffe.Provider, got %q", comment) + } + if !strings.Contains(comment, "/opt/svid.pem") { + t.Errorf("comment should name /opt/svid.pem, got %q", comment) + } + // A healthy provider is not a problem, so nothing should be warned about. + if containsSubstring(res.Warnings, "will NOT be generated") { + t.Errorf("unexpected missing-provider warning: %v", res.Warnings) + } + + // The comment must survive into the rendered file, not just the struct. + out, err := RenderResult(res, "/tmp/c.yaml") + if err != nil { + t.Fatalf("RenderResult: %v", err) + } + if !strings.Contains(string(out), "spiffe.Provider writes /opt/svid.pem") { + t.Errorf("rendered config should explain the cert source:\n%s", out) + } +} + +// mtls with no spiffe block is the weather-service example's shape: nothing +// writes the SVID files, so Praxis cannot bind the listener. That must be a +// warning AND a comment, not a silently broken config. +func TestConvert_MTLSWithoutSPIFFE_WarnsAndComments(t *testing.T) { + res, err := Convert(proxySidecar(t, func(c *config.Config) { + c.MTLS = &config.MTLSConfig{Mode: config.MTLSModePermissive} + // No SPIFFE block at all. + }), nil) + if err != nil { + t.Fatalf("Convert: %v", err) + } + if !containsSubstring(res.Warnings, "/opt/svid.pem") { + t.Errorf("expected a warning naming /opt/svid.pem, got %v", res.Warnings) + } + if !containsSubstring(res.Warnings, "will NOT be generated") { + t.Errorf("expected the warning to say the file is not generated, got %v", res.Warnings) + } + if !containsSubstring(res.Warnings, "spiffe") { + t.Errorf("expected the warning to point at the spiffe block, got %v", res.Warnings) + } + + comment := certComment(t, res) + if !strings.Contains(comment, "will NOT exist") { + t.Errorf("comment should warn the files are absent, got %q", comment) + } + if !strings.Contains(comment, "/opt/svid.pem") { + t.Errorf("comment should name /opt/svid.pem, got %q", comment) + } + + out, err := RenderResult(res, "/tmp/c.yaml") + if err != nil { + t.Fatalf("RenderResult: %v", err) + } + if !strings.Contains(string(out), "WARNING: these files will NOT exist") { + t.Errorf("rendered config should carry the warning comment:\n%s", out) + } +} + +// A provider that runs with mirroring explicitly off keeps SVIDs in memory, +// which Praxis cannot read — same practical outcome as no provider. +func TestConvert_MTLSWithMirrorFilesDisabled_Warns(t *testing.T) { + off := false + res, err := Convert(proxySidecar(t, func(c *config.Config) { + c.MTLS = &config.MTLSConfig{Mode: config.MTLSModeStrict} + c.SPIFFE = &config.SPIFFEConfig{Socket: "unix:///x.sock", MirrorFiles: &off} + }), nil) + if err != nil { + t.Fatalf("Convert: %v", err) + } + if !containsSubstring(res.Warnings, "mirror_files") { + t.Errorf("expected a mirror_files warning, got %v", res.Warnings) + } + if !strings.Contains(certComment(t, res), "will NOT exist") { + t.Errorf("comment should warn the files are absent: %q", certComment(t, res)) + } +} + +// mirror_files unset means the default (true), which is the working case. +func TestConvert_MTLSWithMirrorFilesUnset_NoWarning(t *testing.T) { + res, err := Convert(proxySidecar(t, func(c *config.Config) { + c.MTLS = &config.MTLSConfig{Mode: config.MTLSModeStrict} + c.SPIFFE = &config.SPIFFEConfig{Socket: "unix:///x.sock"} + }), nil) + if err != nil { + t.Fatalf("Convert: %v", err) + } + if containsSubstring(res.Warnings, "mirror_files") { + t.Errorf("mirror_files unset defaults to true and must not warn: %v", res.Warnings) + } +} + +// The comment must track a custom mirror_dir, or it would name a path the +// provider is not writing. +func TestConvert_MTLSCommentUsesMirrorDir(t *testing.T) { + res, err := Convert(proxySidecar(t, func(c *config.Config) { + c.MTLS = &config.MTLSConfig{Mode: config.MTLSModeStrict} + c.SPIFFE = &config.SPIFFEConfig{Socket: "unix:///x.sock", MirrorDir: "/var/run/svid"} + }), nil) + if err != nil { + t.Fatalf("Convert: %v", err) + } + comment := certComment(t, res) + if !strings.Contains(comment, "/var/run/svid/svid.pem") { + t.Errorf("comment should name the configured mirror dir, got %q", comment) + } +} + +// No mtls means no TLS block at all, so there is nothing to warn about even +// without a SPIFFE provider. +func TestConvert_NoMTLS_NoSVIDWarning(t *testing.T) { + res, err := Convert(proxySidecar(t, nil), nil) + if err != nil { + t.Fatalf("Convert: %v", err) + } + if containsSubstring(res.Warnings, "svid.pem") { + t.Errorf("no mtls means no SVID warning, got %v", res.Warnings) + } +} + +// Transparent inbound interception has no Praxis counterpart: Praxis cannot +// recover the original destination per connection. It must be reported rather +// than silently producing a listener pointed somewhere invented. +func TestConvert_TransparentInbound_Warns(t *testing.T) { + cfg := &config.Config{ + Mode: config.ModeProxySidecar, + Listener: config.ListenerConfig{ + Roles: []string{config.RoleReverse, config.RoleForward}, + InboundInterception: config.InboundInterceptionTransparent, + }, + } + config.ApplyPreset(cfg) + res, err := Convert(cfg, nil) + if err != nil { + t.Fatalf("Convert: %v", err) + } + for _, l := range res.Config.Listeners { + if l.Name == ListenerInbound { + t.Error("expected no inbound listener for transparent interception") + } + } + if !containsSubstring(res.Warnings, "SO_ORIGINAL_DST") { + t.Errorf("expected a warning about SO_ORIGINAL_DST, got %v", res.Warnings) + } +} + +// A config that yields no listener at all is an error: Praxis requires at +// least one, so emitting an empty document would just fail later and further +// from the cause. +func TestConvert_NoListeners_IsError(t *testing.T) { + cfg := &config.Config{ + Mode: config.ModeProxySidecar, + Listener: config.ListenerConfig{ + Roles: []string{config.RoleReverse}, + InboundInterception: config.InboundInterceptionTransparent, + }, + } + if _, err := Convert(cfg, nil); err == nil { + t.Fatal("expected an error when no listener can be generated") + } +} + +// Auth plugins must be reported as unmapped, not dropped silently: a +// generated proxy that no longer validates JWTs is a security-relevant +// difference from the AuthBridge config it came from. +func TestConvert_AuthPluginsReportedUnmapped(t *testing.T) { + res, err := Convert(proxySidecar(t, func(c *config.Config) { + c.Pipeline.Inbound.Plugins = []config.PluginEntry{{Name: "jwt-validation"}} + c.Pipeline.Outbound.Plugins = []config.PluginEntry{{Name: "token-exchange"}} + }), nil) + if err != nil { + t.Fatalf("Convert: %v", err) + } + if !containsSubstring(res.Unmapped, "jwt-validation") { + t.Errorf("expected jwt-validation reported unmapped, got %v", res.Unmapped) + } + if !containsSubstring(res.Unmapped, "token-exchange") { + t.Errorf("expected token-exchange reported unmapped, got %v", res.Unmapped) + } +} + +// on_error: off means AuthBridge drops the plugin entirely, so it is not a +// translation gap and must not be reported as one. +func TestConvert_DisabledPluginNotReported(t *testing.T) { + res, err := Convert(proxySidecar(t, func(c *config.Config) { + c.Pipeline.Inbound.Plugins = []config.PluginEntry{{Name: "jwt-validation", OnError: "off"}} + }), nil) + if err != nil { + t.Fatalf("Convert: %v", err) + } + if containsSubstring(res.Unmapped, "jwt-validation") { + t.Errorf("a plugin disabled with on_error: off must not be reported unmapped: %v", res.Unmapped) + } +} + +func TestConvert_UnrecognizedPluginReported(t *testing.T) { + res, err := Convert(proxySidecar(t, func(c *config.Config) { + c.Pipeline.Inbound.Plugins = []config.PluginEntry{{Name: "some-future-plugin"}} + }), nil) + if err != nil { + t.Fatalf("Convert: %v", err) + } + if !containsSubstring(res.Unmapped, "some-future-plugin") { + t.Errorf("expected the unknown plugin reported, got %v", res.Unmapped) + } +} + +// The inbound chain must strip the direction header, matching AuthBridge's +// contract with the application behind it. +func TestConvert_StripsDirectionHeader(t *testing.T) { + res, err := Convert(proxySidecar(t, func(c *config.Config) { + c.Listener.Roles = []string{config.RoleReverse} + }), nil) + if err != nil { + t.Fatalf("Convert: %v", err) + } + found := false + for _, f := range res.Config.FilterChains[0].Filters { + if f.Type != "headers" { + continue + } + for _, fl := range f.Fields { + if fl.Key != "request_remove" { + continue + } + if names, ok := fl.Value.([]string); ok { + for _, n := range names { + if n == DirectionHeader { + found = true + } + } + } + } + } + if !found { + t.Errorf("expected the inbound chain to remove %q", DirectionHeader) + } +} + +func TestConvert_AdminFromStatsAddress(t *testing.T) { + // The admin endpoint takes the HOST from stats.address but binds the health + // port (9091), because /ready and /healthy correspond to AuthBridge's + // /readyz and /healthz — not to the stats endpoints on 9093. + t.Run("loopback needs no override", func(t *testing.T) { + res, err := Convert(proxySidecar(t, func(c *config.Config) { + c.Stats.StatsAddress = "127.0.0.1:9093" + }), nil) + if err != nil { + t.Fatalf("Convert: %v", err) + } + if res.Config.Admin.Address != "127.0.0.1:9091" { + t.Errorf("admin address = %q, want 127.0.0.1:9091", res.Config.Admin.Address) + } + if res.Config.InsecureOptions != nil { + t.Error("loopback admin must not require allow_public_admin") + } + }) + + // AuthBridge deliberately binds stats on all interfaces for the Rossoctl + // UI. Praxis rejects that unless allow_public_admin is set, so the flag + // must travel with the address rather than the address being rewritten. + t.Run("public bind carries the override", func(t *testing.T) { + res, err := Convert(proxySidecar(t, func(c *config.Config) { + c.Stats.StatsAddress = ":9093" + }), nil) + if err != nil { + t.Fatalf("Convert: %v", err) + } + if res.Config.Admin.Address != "0.0.0.0:9091" { + t.Errorf("admin address = %q, want 0.0.0.0:9091", res.Config.Admin.Address) + } + if res.Config.InsecureOptions == nil || !res.Config.InsecureOptions.AllowPublicAdmin { + t.Error("a non-loopback admin bind requires allow_public_admin") + } + }) + + // config.Load always fills stats.address with :9093 when it is empty, so + // this is the shape the binaries actually hand the converter. The admin + // endpoint must still land on the health port. + t.Run("stats port is not carried across", func(t *testing.T) { + res, err := Convert(proxySidecar(t, func(c *config.Config) { + c.Stats.StatsAddress = ":9093" + }), nil) + if err != nil { + t.Fatalf("Convert: %v", err) + } + if strings.HasSuffix(res.Config.Admin.Address, ":9093") { + t.Errorf("admin must not bind the stats port, got %q", res.Config.Admin.Address) + } + }) + + // A non-default stats host must be preserved: it carries the operator's + // reachability intent, which the port does not. + t.Run("non-default host is preserved", func(t *testing.T) { + res, err := Convert(proxySidecar(t, func(c *config.Config) { + c.Stats.StatsAddress = "10.1.2.3:9999" + }), nil) + if err != nil { + t.Fatalf("Convert: %v", err) + } + if res.Config.Admin.Address != "10.1.2.3:9091" { + t.Errorf("admin address = %q, want 10.1.2.3:9091", res.Config.Admin.Address) + } + }) + + // An unset stats address should still yield a loopback admin bind on the + // health port, needing no insecure override. + t.Run("unset stats address falls back to loopback health port", func(t *testing.T) { + res, err := Convert(proxySidecar(t, func(c *config.Config) { + c.Stats.StatsAddress = "" + }), nil) + if err != nil { + t.Fatalf("Convert: %v", err) + } + if res.Config.Admin.Address != "127.0.0.1:9091" { + t.Errorf("admin address = %q, want 127.0.0.1:9091", res.Config.Admin.Address) + } + if res.Config.InsecureOptions != nil { + t.Error("loopback admin must not require allow_public_admin") + } + }) + + // A malformed stats address must not be propagated into the admin bind: + // Praxis parses admin.address as a SocketAddr and would reject it, so the + // fallback keeps one clear failure mode instead of two. + t.Run("malformed stats address falls back", func(t *testing.T) { + res, err := Convert(proxySidecar(t, func(c *config.Config) { + c.Stats.StatsAddress = "not-an-address" + }), nil) + if err != nil { + t.Fatalf("Convert: %v", err) + } + if res.Config.Admin.Address != defaultAdminAddr { + t.Errorf("admin address = %q, want the default %q", + res.Config.Admin.Address, defaultAdminAddr) + } + }) +} + +// AdminPort must match the port AuthBridge's binaries hardcode for their +// health server, since that is the whole reason for choosing it: a readiness +// probe already pointed at 9091 keeps working against the generated proxy. +func TestAdminPort_MatchesAuthBridgeHealthPort(t *testing.T) { + if AdminPort != 9091 { + t.Errorf("AdminPort = %d, want 9091 (AuthBridge's /healthz + /readyz port)", AdminPort) + } +} + +func TestEndpointFromBackendURL(t *testing.T) { + for _, tc := range []struct { + in string + want string + wantErr bool + }{ + {in: "http://localhost:8001", want: "localhost:8001"}, + {in: "https://app:8443", want: "app:8443"}, + {in: "http://127.0.0.1:8001", want: "127.0.0.1:8001"}, + {in: "http://app", want: "app:80"}, + {in: "https://app", want: "app:443"}, + {in: "localhost:8001", want: "localhost:8001"}, + {in: "http://", wantErr: true}, + } { + got, err := endpointFromBackendURL(tc.in) + if tc.wantErr { + if err == nil { + t.Errorf("endpointFromBackendURL(%q) = %q, want an error", tc.in, got) + } + continue + } + if err != nil { + t.Errorf("endpointFromBackendURL(%q): %v", tc.in, err) + continue + } + if got != tc.want { + t.Errorf("endpointFromBackendURL(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestNormalizeBindAddr(t *testing.T) { + for in, want := range map[string]string{ + ":8080": "0.0.0.0:8080", + "0.0.0.0:8080": "0.0.0.0:8080", + "127.0.0.1:8080": "127.0.0.1:8080", + "localhost:8081": "localhost:8081", + } { + if got := normalizeBindAddr(in); got != want { + t.Errorf("normalizeBindAddr(%q) = %q, want %q", in, got, want) + } + } +} + +// Filter entries must marshal flat — the filter's typed fields as siblings of +// `filter`, not nested under a `config:` key. Praxis rejects unknown fields on +// a filter entry, so a nested wrapper would fail to parse. +func TestFilter_MarshalsFlat(t *testing.T) { + f := Filter{ + Type: "router", + Fields: []Field{{Key: "routes", Value: []Route{{PathPrefix: "/", Cluster: "c"}}}}, + } + out, err := yaml.Marshal(f) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + var probe map[string]any + if err := yaml.Unmarshal(out, &probe); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if _, nested := probe["config"]; nested { + t.Errorf("filter entry must not nest fields under 'config':\n%s", out) + } + if probe["filter"] != "router" { + t.Errorf("filter key = %v, want router", probe["filter"]) + } + if _, ok := probe["routes"]; !ok { + t.Errorf("expected 'routes' as a sibling of 'filter':\n%s", out) + } +} + +// The rendered document must parse as YAML and round-trip to the same +// structure, so a malformed comment block can't silently corrupt it. +func TestRenderResult_ParsesAsYAML(t *testing.T) { + res, err := Convert(proxySidecar(t, func(c *config.Config) { + c.Pipeline.Inbound.Plugins = []config.PluginEntry{{Name: "jwt-validation"}} + }), nil) + if err != nil { + t.Fatalf("Convert: %v", err) + } + out, err := RenderResult(res, "/tmp/praxis-config.yaml") + if err != nil { + t.Fatalf("RenderResult: %v", err) + } + var probe struct { + Listeners []struct { + Name string `yaml:"name"` + Address string `yaml:"address"` + } `yaml:"listeners"` + FilterChains []struct { + Name string `yaml:"name"` + } `yaml:"filter_chains"` + } + if err := yaml.Unmarshal(out, &probe); err != nil { + t.Fatalf("generated YAML does not parse: %v\n%s", err, out) + } + if len(probe.Listeners) != len(res.Config.Listeners) { + t.Errorf("round-tripped %d listeners, want %d", len(probe.Listeners), len(res.Config.Listeners)) + } + // The unmapped account must survive into the file, not just the Result. + if !strings.Contains(string(out), "jwt-validation") { + t.Error("expected the unmapped plugin recorded in the rendered file") + } +} + +func TestKnownPlugins_IsSorted(t *testing.T) { + got := KnownPlugins() + if len(got) == 0 { + t.Fatal("expected some known plugins") + } + for i := 1; i < len(got); i++ { + if got[i-1] > got[i] { + t.Errorf("KnownPlugins is not sorted at %d: %q > %q", i, got[i-1], got[i]) + } + } +} + +// TestGeneratedConfig_ValidatesWithPraxis runs the real Praxis binary against +// generated configs. This is the test that actually pins the goal — that +// `praxis -c ` parses and accepts the output — since Praxis's +// validator enforces rules (filter ordering, cluster cross-references, field +// names, admin loopback) that no amount of Go-side assertion can stand in for. +// +// Skipped when no Praxis binary is available, so the suite still runs in CI +// without a Rust toolchain. Set PRAXIS_BIN to point at one explicitly. +func TestGeneratedConfig_ValidatesWithPraxis(t *testing.T) { + bin := findPraxisBinary(t) + if bin == "" { + t.Skip("no praxis binary found; set PRAXIS_BIN to enable this test") + } + + cases := []struct { + name string + mutate func(*config.Config) + }{ + {name: "both roles, no plugins", mutate: nil}, + { + name: "reverse only", + mutate: func(c *config.Config) { + c.Listener.Roles = []string{config.RoleReverse} + }, + }, + { + name: "forward only", + mutate: func(c *config.Config) { + c.Listener.Roles = []string{config.RoleForward} + }, + }, + { + name: "full auth pipeline", + mutate: func(c *config.Config) { + c.Pipeline.Inbound.Plugins = []config.PluginEntry{{Name: "jwt-validation"}} + c.Pipeline.Outbound.Plugins = []config.PluginEntry{ + {Name: "mcp-parser"}, {Name: "token-exchange"}, + } + }, + }, + { + name: "strict mTLS", + mutate: func(c *config.Config) { + c.MTLS = &config.MTLSConfig{Mode: config.MTLSModeStrict} + }, + }, + { + name: "permissive mTLS", + mutate: func(c *config.Config) { + c.MTLS = &config.MTLSConfig{Mode: config.MTLSModePermissive} + }, + }, + { + name: "public stats bind", + mutate: func(c *config.Config) { + c.Stats.StatsAddress = ":9093" + }, + }, + { + name: "loopback stats bind", + mutate: func(c *config.Config) { + c.Stats.StatsAddress = "127.0.0.1:9093" + }, + }, + { + name: "every known plugin", + mutate: func(c *config.Config) { + for _, n := range KnownPlugins() { + c.Pipeline.Outbound.Plugins = append( + c.Pipeline.Outbound.Plugins, config.PluginEntry{Name: n}) + } + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg := &config.Config{ + Mode: config.ModeProxySidecar, + Listener: config.ListenerConfig{ + ReverseProxyBackend: "http://localhost:8001", + }, + } + if tc.mutate != nil { + tc.mutate(cfg) + } + config.ApplyPreset(cfg) + + res, err := Convert(cfg, nil) + if err != nil { + t.Fatalf("Convert: %v", err) + } + out, err := RenderResult(res, "generated.yaml") + if err != nil { + t.Fatalf("RenderResult: %v", err) + } + path := filepath.Join(t.TempDir(), "praxis-config.yaml") + if err := os.WriteFile(path, out, 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + cmd := exec.Command(bin, "-t", "-c", path) + combined, err := cmd.CombinedOutput() + if err != nil { + t.Errorf("praxis rejected the generated config: %v\n--- praxis output ---\n%s\n--- config ---\n%s", + err, combined, out) + } + }) + } +} + +// findPraxisBinary locates a Praxis binary to validate against: PRAXIS_BIN +// first, then the usual cargo target directories under ~/src/praxis. +func findPraxisBinary(t *testing.T) string { + t.Helper() + if p := os.Getenv("PRAXIS_BIN"); p != "" { + if _, err := os.Stat(p); err == nil { + return p + } + t.Fatalf("PRAXIS_BIN=%q does not exist", p) + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + for _, rel := range []string{"src/praxis/target/debug/praxis", "src/praxis/target/release/praxis"} { + p := filepath.Join(home, rel) + if _, err := os.Stat(p); err == nil { + return p + } + } + return "" +} + +// lastFilter returns the last filter of the given type in a chain. +func lastFilter(t *testing.T, chain FilterChain, typ string) Filter { + t.Helper() + for i := len(chain.Filters) - 1; i >= 0; i-- { + if chain.Filters[i].Type == typ { + return chain.Filters[i] + } + } + t.Fatalf("no %q filter in chain %q", typ, chain.Name) + return Filter{} +} + +func containsSubstring(haystack []string, needle string) bool { + for _, h := range haystack { + if strings.Contains(h, needle) { + return true + } + } + return false +} diff --git a/authbridge/authlib/praxis/render.go b/authbridge/authlib/praxis/render.go new file mode 100644 index 000000000..cbafd2a96 --- /dev/null +++ b/authbridge/authlib/praxis/render.go @@ -0,0 +1,190 @@ +package praxis + +import ( + "bytes" + "fmt" + "strings" + + "gopkg.in/yaml.v3" +) + +// MarshalYAML flattens a filter entry into a single mapping. +// +// Praxis filter entries carry the structural keys (`filter`, `name`, +// `conditions`, `response_conditions`, `failure_mode`, `branch_chains`) +// alongside the filter's own typed fields at the SAME level — upstream this is +// `#[serde(flatten)]` into a serde_yaml::Value, which is then stripped of the +// structural keys and deserialized into the filter's typed struct with +// unknown-field rejection. Emitting the typed fields under a nested `config:` +// key would therefore be rejected as an unknown field by every builtin filter. +// +// Comments are attached as a head comment on the `filter` key so they render +// immediately above the entry. +func (f Filter) MarshalYAML() (any, error) { + node := &yaml.Node{Kind: yaml.MappingNode} + + filterKey := &yaml.Node{Kind: yaml.ScalarNode, Value: "filter"} + if len(f.Comments) > 0 { + // Wrap each comment so long explanatory notes stay readable in the + // emitted file instead of running off to a single very long line. + var lines []string + for _, c := range f.Comments { + lines = append(lines, wrapComment(c, 72)...) + } + filterKey.HeadComment = strings.Join(lines, "\n") + } + filterVal := &yaml.Node{Kind: yaml.ScalarNode, Value: f.Type} + node.Content = append(node.Content, filterKey, filterVal) + + for _, field := range f.Fields { + valNode := &yaml.Node{} + if err := valNode.Encode(field.Value); err != nil { + return nil, fmt.Errorf("praxis: encoding filter %q field %q: %w", f.Type, field.Key, err) + } + node.Content = append(node.Content, + &yaml.Node{Kind: yaml.ScalarNode, Value: field.Key}, + valNode, + ) + } + return node, nil +} + +// header is the comment block prepended to every generated document. It names +// the generator and states the one thing a reader most needs to know: which +// AuthBridge behavior did and did not survive the translation. +const header = `# Praxis proxy configuration — GENERATED from an AuthBridge config. +# +# Generated by authbridge/authlib/praxis (Convert). Edits are overwritten on +# the next run; change the AuthBridge config instead. +# +# Run with: +# cargo run -p praxis-proxy -- -c %s +# Validate without binding ports: +# cargo run -p praxis-proxy -- -t -c %s +# +# SCOPE OF THE TRANSLATION +# Structural configuration — listener addresses, the application backend, +# routing, mTLS posture, and the admin/metrics endpoint — is translated +# faithfully. AuthBridge's plugin *enforcement* largely is not: a +# default-feature Praxis build (features = []) compiles in no JWT-validation +# and no RFC 8693 token-exchange filter. Praxis's ` + "`policy`" + ` filter covers +# that ground but requires the off-by-default ` + "`policy-engine`" + ` cargo feature +# plus a separate policy document, and a default build rejects it outright +# ("unknown filter type: 'policy'"). +# +# Any AuthBridge plugin without a counterpart is marked UNMAPPED in a comment +# at the position it occupied in the pipeline. Those markers are inert: they +# record the gap, they do not enforce the plugin. Read them before putting this +# in front of anything that was relying on AuthBridge to say no. +` + +// Render marshals a Praxis config to YAML with the explanatory header. +// +// configPath is used only to make the header's example commands +// copy-pasteable; it does not have to exist yet. +func Render(cfg *Config, configPath string) ([]byte, error) { + if cfg == nil { + return nil, fmt.Errorf("praxis: nil config") + } + return renderYAML(fmt.Sprintf(header, configPath, configPath), cfg) +} + +// renderYAML writes a comment header followed by v marshalled as YAML. Shared +// by the proxy config and the policy document so both documents use the same +// indentation and header convention. +func renderYAML(header string, v any) ([]byte, error) { + var buf bytes.Buffer + buf.WriteString(header) + buf.WriteString("\n") + + enc := yaml.NewEncoder(&buf) + enc.SetIndent(2) + if err := enc.Encode(v); err != nil { + return nil, fmt.Errorf("praxis: encoding YAML: %w", err) + } + if err := enc.Close(); err != nil { + return nil, fmt.Errorf("praxis: closing encoder: %w", err) + } + return buf.Bytes(), nil +} + +// RenderResult marshals a conversion result, appending the unmapped-plugin +// account and structural warnings as a trailing comment block so the +// generated file carries its own caveats. +func RenderResult(res *Result, configPath string) ([]byte, error) { + if res == nil { + return nil, fmt.Errorf("praxis: nil result") + } + out, err := Render(res.Config, configPath) + if err != nil { + return nil, err + } + if len(res.Unmapped) == 0 && len(res.Warnings) == 0 { + return out, nil + } + + var buf bytes.Buffer + buf.Write(out) + buf.WriteString("\n") + if len(res.Unmapped) > 0 { + buf.WriteString("# ── UNMAPPED AUTHBRIDGE PLUGINS ") + buf.WriteString(strings.Repeat("─", 44)) + buf.WriteString("\n#\n") + buf.WriteString("# These plugins were NOT translated. The proxy below does not enforce them.\n#\n") + for _, u := range res.Unmapped { + writeCommentBlock(&buf, u) + } + } + if len(res.Warnings) > 0 { + buf.WriteString("#\n# ── STRUCTURAL NOTES ") + buf.WriteString(strings.Repeat("─", 50)) + buf.WriteString("\n#\n") + for _, w := range res.Warnings { + writeCommentBlock(&buf, w) + } + } + return buf.Bytes(), nil +} + +// wrapComment splits s into lines of at most width runes, breaking on word +// boundaries. A word longer than width is left on its own line rather than +// being split mid-token, so paths and URLs stay copy-pasteable. +func wrapComment(s string, width int) []string { + words := strings.Fields(s) + if len(words) == 0 { + return nil + } + var ( + lines []string + cur strings.Builder + ) + for _, w := range words { + if cur.Len() > 0 && cur.Len()+1+len(w) > width { + lines = append(lines, cur.String()) + cur.Reset() + } + if cur.Len() > 0 { + cur.WriteByte(' ') + } + cur.WriteString(w) + } + if cur.Len() > 0 { + lines = append(lines, cur.String()) + } + return lines +} + +// writeCommentBlock emits one bullet as wrapped YAML comment lines, with +// continuation lines indented under the bullet. +func writeCommentBlock(buf *bytes.Buffer, s string) { + lines := wrapComment(s, 68) + for i, l := range lines { + if i == 0 { + buf.WriteString("# - ") + } else { + buf.WriteString("# ") + } + buf.WriteString(l) + buf.WriteString("\n") + } +} diff --git a/authbridge/cmd/authbridge-praxis/Dockerfile b/authbridge/cmd/authbridge-praxis/Dockerfile new file mode 100644 index 000000000..3a3d6b808 --- /dev/null +++ b/authbridge/cmd/authbridge-praxis/Dockerfile @@ -0,0 +1,142 @@ +# AuthBridge praxis-sidecar combined image — the authbridge-praxis config +# generator plus a Praxis proxy binary, in a single container. +# +# How this differs from the other AuthBridge images: authbridge-praxis is not a +# proxy. It converts an AuthBridge config into a Praxis config (and, when the +# inbound pipeline declares jwt-validation, a Praxis policy document), and +# Praxis is the data plane. The entrypoint therefore runs the generator to +# completion first, then execs Praxis against the generated config. See +# entrypoint.sh for the ordering guarantees. +# +# The Praxis binary is built with `--features policy-engine`, which is OFF in +# Praxis's default feature set. That feature is what compiles in the `policy` +# filter; without it Praxis rejects the generated config outright with +# "unknown filter type: 'policy'" whenever a policy document was produced. +# Since the generator emits that filter for jwt-validation, the two must be +# built together — a default-feature Praxis in this image would fail to start +# on exactly the configs that need inbound auth. +# +# Build context: ./authbridge (needs access to authlib/, storage/, and +# cmd/authbridge-praxis/) +# +# Praxis source is not vendored in this repo, so the Rust stage fetches it from +# git. PRAXIS_REPO / PRAXIS_REF pin what gets built; PRAXIS_REF defaults to a +# commit rather than a branch so an image rebuild is reproducible. Point +# PRAXIS_REPO at a fork to build that instead. + +# ── Stage 1: Build the Praxis proxy binary (with the policy engine) ───────── +# +# Mirrors the recipe in Praxis's own Containerfile: rust:1.96-alpine (matching +# its rust-toolchain.toml) plus the musl / OpenSSL / cmake toolchain its +# dependency tree needs. OPENSSL_STATIC links OpenSSL into the binary so the +# runtime stage needs no OpenSSL package. +FROM rust:1.96-alpine AS praxis-builder + +ENV OPENSSL_STATIC=1 + +RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static pkgconf cmake make g++ git + +ARG PRAXIS_REPO=https://github.com/praxis-proxy/praxis.git +# A release tag, not a branch: pinning to `main` would make image rebuilds +# non-reproducible and could silently pick up a schema change the generator in +# stage 2 does not emit for. v0.5.3 carries the policy-engine feature and the +# `policy` filter fields this image's generated configs use. +ARG PRAXIS_REF=v0.5.3 + +WORKDIR /praxis + +# Fetch the pinned ref without cloning full history. Three shapes of +# PRAXIS_REF are accepted, tried cheapest-first: a tag (the default), then a +# bare SHA or branch via a direct refspec fetch, then all branch tips as a last +# resort. The tag path is listed first because fetching an arbitrary commit by +# SHA requires the server to allow it, which GitHub does not always do — so a +# SHA-pinned build can fall through to the later forms. +RUN git init -q . \ + && git remote add origin "${PRAXIS_REPO}" \ + && ( git fetch -q --depth 1 origin "refs/tags/${PRAXIS_REF}:refs/tags/${PRAXIS_REF}" \ + || git fetch -q --depth 1 origin "${PRAXIS_REF}" \ + || git fetch -q --depth 1 origin '+refs/heads/*:refs/remotes/origin/*' ) \ + && ( git checkout -q "${PRAXIS_REF}" || git checkout -q FETCH_HEAD ) \ + && git --no-pager log --oneline -1 + +# `policy-engine` pulls in the praxis-policy crates (identity/jwt, Cedar PDP, +# OAuth delegator). Build only the server crate; the workspace's test and +# benchmark members are not needed for the binary. +RUN cargo build --release -p praxis-proxy --features policy-engine \ + && cp target/release/praxis /praxis-bin \ + && strip /praxis-bin + +# Fail the build here rather than at container start if the feature flag did +# not take effect: a Praxis without the `policy` filter cannot run the configs +# this image generates. A missing config_path proves the filter is registered +# (an unregistered filter reports "unknown filter type" instead). +# +# The probe is EXPECTED to exit non-zero (its config_path is deliberately +# absent), so its output is redirected to a file and inspected rather than +# piped — a pipeline would either mask the status or, under pipefail, abort +# before the grep that actually decides the outcome. +RUN printf 'listeners:\n\ + - name: probe\n\ + address: "127.0.0.1:18099"\n\ + filter_chains: [c]\n\ +filter_chains:\n\ + - name: c\n\ + filters:\n\ + - filter: policy\n\ + config_path: /nonexistent/policy.yaml\n\ + - filter: static_response\n\ + status: 200\n' > /probe.yaml \ + && { /praxis-bin --validate --config /probe.yaml > /probe.out 2>&1 || true; } \ + && cat /probe.out \ + && if grep -q "unknown filter type" /probe.out; then \ + echo "FATAL: praxis was built WITHOUT the policy-engine feature" >&2; exit 1; \ + fi \ + && rm -f /probe.yaml /probe.out + +# ── Stage 2: Build the authbridge-praxis config generator ─────────────────── +# +# `-ldflags="-s -w"` drops the symbol table and DWARF debug info to shave ~30% +# off the binary. Go's runtime keeps `pclntab` separately, so panic stack +# traces still show function names. +FROM golang:1.26-alpine@sha256:0178a641fbb4858c5f1b48e34bdaabe0350a330a1b1149aabd498d0699ff5fb2 AS go-builder + +RUN apk add --no-cache git + +WORKDIR /app + +COPY authlib/ authlib/ +COPY storage/ storage/ +COPY cmd/authbridge-praxis/ cmd/authbridge-praxis/ + +ARG GO_BUILD_TAGS="" +ENV GOWORK=off +RUN cd cmd/authbridge-praxis && CGO_ENABLED=0 GOOS=linux go build -tags "${GO_BUILD_TAGS}" -ldflags="-s -w" -o /authbridge-praxis . + +# ── Stage 3: Runtime ─────────────────────────────────────────────────────── +# +# alpine (has bash for the entrypoint, ~5 MB base). authbridge-praxis is +# CGO_ENABLED=0 static; the Praxis binary is musl-linked against the same libc +# family as this base, with OpenSSL statically linked in. +FROM alpine:3.20@sha256:beefdbd8a1da6d2915566fde36db9db0b524eb737fc57cd1367effd16dc0d06d + +RUN apk add --no-cache bash ca-certificates + +COPY --from=go-builder --chmod=755 /authbridge-praxis /usr/local/bin/authbridge-praxis +COPY --from=praxis-builder --chmod=755 /praxis-bin /usr/local/bin/praxis +COPY --chmod=755 cmd/authbridge-praxis/entrypoint.sh /usr/local/bin/entrypoint.sh + +# The generator writes into /tmp and Praxis reads from there, so the runtime +# user needs a writable /tmp. Alpine ships it 1777, which covers an arbitrary +# UID; created explicitly so the contract is visible rather than inherited. +RUN mkdir -p /tmp && chmod 1777 /tmp + +USER 1001 + +# 8080 reverse proxy / 8081 forward proxy — the listener addresses the +# generator derives from the AuthBridge config. 9091 is the Praxis admin +# endpoint (/healthy, /ready, /metrics); the generator binds it on +# AuthBridge's health port so an existing /readyz probe keeps working against +# Praxis's /ready. +EXPOSE 8080 8081 9091 + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/authbridge/cmd/authbridge-praxis/entrypoint.sh b/authbridge/cmd/authbridge-praxis/entrypoint.sh new file mode 100644 index 000000000..361bccbff --- /dev/null +++ b/authbridge/cmd/authbridge-praxis/entrypoint.sh @@ -0,0 +1,76 @@ +#!/bin/bash +set -eu + +# AuthBridge praxis-sidecar combined entrypoint. +# +# Unlike the other AuthBridge entrypoints, this one is a two-phase pipeline +# rather than a process supervisor, because authbridge-praxis is a config +# GENERATOR, not a proxy: +# +# Phase 1: authbridge-praxis — reads the AuthBridge config and writes a +# Praxis proxy config (+ a Praxis policy document when the inbound +# pipeline declares something the policy engine can enforce). Runs +# to completion and exits. +# Phase 2: praxis — the actual data plane, exec'd against the generated +# config so it becomes PID 1. +# +# Phase 1 must succeed before phase 2 starts. `set -e` guarantees that: if +# authbridge-praxis exits non-zero (bad AuthBridge config, unconvertible +# pipeline, unwritable output path) the script aborts and the container exits +# non-zero, so Kubernetes restarts it. Starting Praxis anyway would serve +# traffic through a stale config from a previous run — or fail confusingly on +# a missing file — and in the inbound case that could mean proxying without +# the JWT validation the operator configured. +# +# Phase 2 uses `exec` so Praxis replaces this shell as PID 1. That gives it +# signals directly from the kubelet (it drains gracefully on SIGTERM) and +# leaves no supervisor to forward them. There is no third process to +# supervise, so the multi-process supervision the other entrypoints do would +# add nothing here. + +# Where the generator writes, and Praxis reads. Overridable so the pair can be +# relocated together; the two MUST agree, which is why one variable feeds both +# the generator flag and the Praxis flag rather than being written twice. +PRAXIS_CONFIG="${PRAXIS_CONFIG:-/tmp/praxis-config.yaml}" +PRAXIS_POLICY="${PRAXIS_POLICY:-/tmp/praxis-policy.yaml}" + +# The AuthBridge config to convert. Defaults to the same path the other +# AuthBridge images mount their runtime config at. +AUTHBRIDGE_CONFIG="${AUTHBRIDGE_CONFIG:-/etc/authbridge/config.yaml}" + +# --- Phase 1: generate the Praxis config from the AuthBridge config --- +echo "[entrypoint] Generating Praxis config from ${AUTHBRIDGE_CONFIG}..." +/usr/local/bin/authbridge-praxis \ + --config "${AUTHBRIDGE_CONFIG}" \ + --praxis-config-out "${PRAXIS_CONFIG}" \ + --praxis-policy-out "${PRAXIS_POLICY}" \ + "$@" + +# Fail loudly rather than handing Praxis a path that does not exist: the +# generator reports success by exiting 0, but a wrong --praxis-config-out or a +# read-only target would leave nothing behind, and `praxis -c` on a missing +# file is a much less obvious error than this one. +if [ ! -s "${PRAXIS_CONFIG}" ]; then + echo "[entrypoint] ERROR: ${PRAXIS_CONFIG} was not written (or is empty); refusing to start Praxis" >&2 + exit 1 +fi + +echo "[entrypoint] Wrote ${PRAXIS_CONFIG}" +if [ -s "${PRAXIS_POLICY}" ]; then + echo "[entrypoint] Wrote ${PRAXIS_POLICY} (policy engine will enforce it)" +else + # Not an error: a pipeline with no jwt-validation has nothing for the policy + # engine to enforce, and in that case the generated config carries no + # `policy` filter either, so there is no dangling reference. + echo "[entrypoint] No policy document generated (nothing in the inbound pipeline maps to a policy plugin)" +fi + +# --- Phase 2: run Praxis against the generated config --- +# Validate before binding ports. Praxis checks filter ordering, cluster +# cross-references, and the policy document itself; catching a bad generated +# config here produces one clear error instead of a partially-bound proxy. +echo "[entrypoint] Validating generated config..." +/usr/local/bin/praxis --validate --config "${PRAXIS_CONFIG}" + +echo "[entrypoint] Starting praxis with ${PRAXIS_CONFIG}..." +exec /usr/local/bin/praxis --config "${PRAXIS_CONFIG}" diff --git a/authbridge/cmd/authbridge-praxis/go.mod b/authbridge/cmd/authbridge-praxis/go.mod new file mode 100644 index 000000000..dada2a923 --- /dev/null +++ b/authbridge/cmd/authbridge-praxis/go.mod @@ -0,0 +1,37 @@ +module github.com/rossoctl/cortex/authbridge/cmd/authbridge-praxis + +go 1.26.4 + +require github.com/rossoctl/cortex/authbridge/authlib v0.0.0-20260819180630-8386e3004363 + +require ( + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/goccy/go-json v0.10.6 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/lestrrat-go/blackmagic v1.0.4 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc v1.0.6 // indirect + github.com/lestrrat-go/iter v1.0.2 // indirect + github.com/lestrrat-go/jwx/v2 v2.1.7 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/grpc v1.82.0 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +// Build against the in-tree authlib rather than a published version. go.work +// provides this during local development, but container builds set GOWORK=off +// (the workspace's sibling modules are not in the build context), and without +// the replace the module proxy would supply an older authlib — one that +// predates authlib/praxis and fails the build. Mirrors cmd/authbridge-proxy. +replace github.com/rossoctl/cortex/authbridge/authlib => ../../authlib diff --git a/authbridge/cmd/authbridge-praxis/go.sum b/authbridge/cmd/authbridge-praxis/go.sum new file mode 100644 index 000000000..f5fce2cf7 --- /dev/null +++ b/authbridge/cmd/authbridge-praxis/go.sum @@ -0,0 +1,89 @@ +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= +github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= +github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= +github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= +github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= +github.com/lestrrat-go/jwx/v2 v2.1.7 h1:bnYeET+S8IOyAw6W4LTc6SEeK7Xs58SKKZkR7scb3Ko= +github.com/lestrrat-go/jwx/v2 v2.1.7/go.mod h1:exQ9ZBuN1cMLYmxwhTlHUru08ykONG0z+HbLEeDG9qo= +github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= +github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rossoctl/cortex/authbridge/authlib v0.0.0-20260819180630-8386e3004363 h1:GWfyy54ml3PsfAGnUcA3C4ngvplQ7ViTQz5/oKG79Ls= +github.com/rossoctl/cortex/authbridge/authlib v0.0.0-20260819180630-8386e3004363/go.mod h1:50Z+bd+yCg07M2lEdhNcHaPlquSOBKMJPkx6xrIyca0= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/spiffe/go-spiffe/v2 v2.8.1 h1:eXZMLsu+3MLEPJyGJkolqtVrteZfQdUpOWj6LTiDl/E= +github.com/spiffe/go-spiffe/v2 v2.8.1/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= +google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/authbridge/cmd/authbridge-praxis/main.go b/authbridge/cmd/authbridge-praxis/main.go new file mode 100644 index 000000000..4c5b004f6 --- /dev/null +++ b/authbridge/cmd/authbridge-praxis/main.go @@ -0,0 +1,271 @@ +// Package main is the Praxis authbridge binary: Praxis reverse proxy, +// no forward proxy. +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "log" + "log/slog" + "os" + + "github.com/rossoctl/cortex/authbridge/authlib/config" + "github.com/rossoctl/cortex/authbridge/authlib/praxis" + "github.com/rossoctl/cortex/authbridge/authlib/runtimeutil" + "github.com/rossoctl/cortex/authbridge/authlib/spiffe" + // Only HTTP listeners are compiled in: no extproc/extauthz + // (no gRPC, no envoy types). + // Plugins are wired via per-plugin plugins_.go files, each gated + // by `//go:build !exclude_plugin_`. main.go imports no plugin + // package directly, so every plugin can be dropped at build time. The + // authbridge-lite image excludes all but jwt-validation + token-exchange. +) + +// version is the authbridge-proxy build version, overridden at release time +// via -ldflags "-X main.version=". Defaults to "dev" for local builds. +var version = "dev" + +// 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 +} + +// spiffeIdentityType is the `identity.type` config value that selects the +// SPIFFE identity scheme. It is a shared config convention (token-exchange is +// the only consumer today); kept as a local constant so main.go stays +// decoupled from any specific plugin package — every plugin is build-tag +// excludable via plugins_.go. +const spiffeIdentityType = "spiffe" + +// 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 == spiffeIdentityType +} + +// defaultPraxisConfigPath is where the generated Praxis configuration is +// written. Matches the path the Praxis run command in the package docs uses: +// +// cargo run -p praxis-proxy -- -c /tmp/praxis-config.yaml +const defaultPraxisConfigPath = "/tmp/praxis-config.yaml" + +// defaultPraxisPolicyPath is where the generated Praxis policy document is +// written. It is a second file, referenced by the proxy config's `policy` +// filter via config_path, and it is what makes the generated proxy enforce +// inbound JWT validation. +const defaultPraxisPolicyPath = "/tmp/praxis-policy.yaml" + +func main() { + configPath := flag.String("config", "", "path to config YAML file") + praxisOut := flag.String("praxis-config-out", defaultPraxisConfigPath, + "path to write the generated Praxis configuration to") + praxisPolicyOut := flag.String("praxis-policy-out", defaultPraxisPolicyPath, + "path to write the generated Praxis policy document to (referenced by the "+ + "generated config's policy filter; requires Praxis built with --features policy-engine)") + showVersion := flag.Bool("version", false, "print version and exit") + flag.Parse() + + if *showVersion { + fmt.Println("authbridge-proxy", version) + return + } + + runtimeutil.InitLogging("authbridge-praxis") + runtimeutil.StartSignalToggle() + + if *configPath == "" { + log.Fatal("--config is required (or use --demo for the local demo)") + } + + // Build the SPIFFE Provider when the spiffe block is configured. The + // Provider drives both mTLS (via X509Source) and token-exchange's + // spiffe identity (via JWTSource). Construction blocks until the first + // X.509-SVID arrives (cold-start gate); kubelet restarts on failure. + // + // We need cfg first to read the spiffe block, so do a one-shot Load + // before buildPipelines runs (buildPipelines re-Loads internally for + // hot-reload). The Provider is captured by buildPipelines via closure + // so reload-time pipeline rebuilds inject the same Provider into + // freshly constructed plugin instances. + bootCfg, err := config.Load(*configPath) + if err != nil { + log.Fatalf("failed to load config %q: %v", *configPath, err) + } + slog.Debug("config loaded", "configPath", *configPath) + + // Fill mode-specific listener defaults and validate the mode/listener + // combination before converting. The Praxis conversion reads resolved + // listener addresses (reverse_proxy_addr, forward_proxy_addr, ...) rather + // than re-deriving them, so the preset must run first — otherwise a config + // that relies on defaults would convert to a listener-less Praxis document. + config.ApplyPreset(bootCfg) + if err := config.Validate(bootCfg); err != nil { + log.Fatalf("invalid config %q: %v", *configPath, 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 && spiffeProviderNeeded(bootCfg) { + mirrorFiles := true + if bootCfg.SPIFFE.MirrorFiles != nil { + mirrorFiles = *bootCfg.SPIFFE.MirrorFiles + } + slog.Debug("About to create SPIFFE Provider", "bootCfg.SPIFFE.Socket", bootCfg.SPIFFE.Socket) + provider, err = spiffe.NewProvider(context.Background(), spiffe.ProviderConfig{ + SocketPath: bootCfg.SPIFFE.Socket, + MirrorFiles: mirrorFiles, + MirrorDir: bootCfg.SPIFFE.MirrorDir, + }) + if err != nil { + log.Fatalf("spiffe provider: %v", err) + } + defer provider.Close() + slog.Debug("SPIFFE provider created", "bootCfg.SPIFFE.Socket", bootCfg.SPIFFE.Socket) + } 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") + } else { + slog.Debug("Config does not use SPIFFE") + } + + // Note that hot reload is not yet supported + + // Translate the AuthBridge config into a Praxis configuration and write it + // out, so Praxis can be started against it: + // + // cargo run -p praxis-proxy -- -c /tmp/praxis-config.yaml + // + // Conversion is lossy by necessity: a default-feature Praxis build has no + // JWT-validation or RFC 8693 token-exchange filter, so AuthBridge's auth + // plugins have no counterpart there. Convert reports those rather than + // dropping them silently, and they are logged at WARN below — a generated + // proxy that no longer enforces inbound auth is precisely the kind of thing + // that must not be discoverable only by reading the output file. + if err := writePraxisConfig(bootCfg, *praxisOut, *praxisPolicyOut); err != nil { + log.Fatalf("failed to write Praxis config: %v", err) + } + + // This binary generates configuration; it does not proxy. Praxis itself is + // the data plane, started separately against the files just written — in + // the container image, by the entrypoint that runs this binary first. + // + // Exiting 0 is what makes that chaining possible: the entrypoint runs this + // binary to completion and then execs Praxis only if it succeeded, so a + // conversion failure (which returns non-zero via log.Fatalf above) stops + // the container instead of starting a proxy against a stale or absent + // config. + slog.Info("config generation complete; this binary does not proxy", + "config", *praxisOut, "policy", *praxisPolicyOut) + slog.Info("run Praxis against the generated config", + "cmd", "praxis -c "+*praxisOut, + "note", "requires a Praxis built with --features policy-engine when a policy was written") +} + +// writePraxisConfig converts cfg to a Praxis configuration and writes it to +// outPath, along with the Praxis policy document at policyPath. +// +// The policy document is what lets the generated proxy actually enforce +// AuthBridge's inbound JWT validation: Praxis's `policy` filter reads it and +// denies requests without a valid token. It is written only when the AuthBridge +// pipeline declares something the policy engine can enforce — otherwise no file +// is created and the proxy config carries no `policy` filter, since a `policy` +// filter pointing at a nonexistent path fails Praxis startup. +// +// Note the generated `policy` filter requires Praxis to be built with the +// policy-engine cargo feature; a default-feature build rejects it as an unknown +// filter type. +func writePraxisConfig(cfg *config.Config, outPath, policyPath string) error { + res, pol, err := praxis.ConvertWithPolicy(cfg, policyPath) + if err != nil { + return fmt.Errorf("converting config: %w", err) + } + + // Write the policy first: the proxy config references it by path, and + // Praxis fails to start if that path is missing. Writing the referrer + // before the referent would leave a broken pair behind on a policy write + // failure. + if pol.Document != nil { + policyData, err := praxis.RenderPolicyResult(pol, outPath) + if err != nil { + return fmt.Errorf("rendering policy: %w", err) + } + // 0o644: the policy references a JWKS URL and issuer/audience values, + // never key material or secrets, and Praxis may run as another user. + if err := os.WriteFile(policyPath, policyData, 0o644); err != nil { + return fmt.Errorf("writing policy %q: %w", policyPath, err) + } + slog.Info("wrote Praxis policy", + "path", policyPath, + "enforces", pol.Enforced) + } else { + slog.Info("no Praxis policy written (nothing in the inbound pipeline maps to a policy plugin)", + "path", policyPath) + } + + data, err := praxis.RenderResult(res, outPath) + if err != nil { + return fmt.Errorf("rendering config: %w", err) + } + // 0o644: the generated config carries no secrets (it references credential + // and SVID paths, never their contents) and Praxis may run as another user. + if err := os.WriteFile(outPath, data, 0o644); err != nil { + return fmt.Errorf("writing %q: %w", outPath, err) + } + + slog.Info("wrote Praxis config", + "path", outPath, + "listeners", len(res.Config.Listeners), + "filterChains", len(res.Config.FilterChains), + "unmappedPlugins", len(res.Unmapped)) + for _, u := range res.Unmapped { + slog.Warn("AuthBridge plugin not represented in the generated Praxis config", "detail", u) + } + for _, w := range res.Warnings { + slog.Warn("Praxis config translation note", "detail", w) + } + return nil +} diff --git a/authbridge/go.work b/authbridge/go.work index 03ea6bcb8..cf30a6c6a 100644 --- a/authbridge/go.work +++ b/authbridge/go.work @@ -5,6 +5,7 @@ use ( ./cmd/abctl ./cmd/authbridge-cpex ./cmd/authbridge-envoy + ./cmd/authbridge-praxis ./cmd/authbridge-proxy ./storage/redis ) From cf52d78ef252aa168140b40e89c015a28b5f4015 Mon Sep 17 00:00:00 2001 From: Ed Snible Date: Fri, 21 Aug 2026 09:47:38 -0400 Subject: [PATCH 2/7] fix(praxis): Bump grpc to 1.82.1 for GHSA xDS RBAC / HTTP-2 fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dependency Review failed PR #787 on a high-severity advisory: authbridge/cmd/authbridge-praxis/go.mod » google.golang.org/grpc@1.82.0 gRPC-Go: xDS RBAC and HTTP/2 Vulnerabilities (high severity) grpc arrives here indirectly via authlib's SPIFFE Workload API client. 1.82.0 is what every other module in the repo already pins, so the advisory is pre-existing repo-wide; it trips the gate here only because a new go.mod presents the dependency as newly added. Bumped to 1.82.1, the first patched version, in this module alone — narrowest change that clears the gate. The other modules still pin 1.82.0 and should be bumped separately. Checked the rest of this module's dependency set against the GitHub advisory DB while here: jsonparser, go-jose, and glog all already sit at or above their patched versions, so grpc was the only real gap. Assisted-By: Claude (Anthropic AI) Signed-off-by: Ed Snible --- authbridge/cmd/authbridge-praxis/go.mod | 2 +- authbridge/cmd/authbridge-praxis/go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/authbridge/cmd/authbridge-praxis/go.mod b/authbridge/cmd/authbridge-praxis/go.mod index dada2a923..b39607c23 100644 --- a/authbridge/cmd/authbridge-praxis/go.mod +++ b/authbridge/cmd/authbridge-praxis/go.mod @@ -24,7 +24,7 @@ require ( golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.40.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/grpc v1.82.0 // indirect + google.golang.org/grpc v1.82.1 // indirect google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/authbridge/cmd/authbridge-praxis/go.sum b/authbridge/cmd/authbridge-praxis/go.sum index f5fce2cf7..c9904f4b2 100644 --- a/authbridge/cmd/authbridge-praxis/go.sum +++ b/authbridge/cmd/authbridge-praxis/go.sum @@ -79,6 +79,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From aa003fe26a02dbf975b173b594bd97d0d3dc9604 Mon Sep 17 00:00:00 2001 From: Ed Snible Date: Fri, 21 Aug 2026 10:12:26 -0400 Subject: [PATCH 3/7] chore: Ignore mlflow.db and stray cmd binaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mlflow.db (plus mlruns/) is MLflow's local tracking store, created by runs in the working tree — local experiment state, not project source. Also extends the existing Go build-artifact list: authbridge-praxis was missing, and `go build ./cmd/...` from authbridge/ writes binaries into that directory rather than each package dir, so those spellings need ignoring too. Both spellings were trivially `git add`-able by mistake. Assisted-By: Claude (Anthropic AI) Signed-off-by: Ed Snible --- .gitignore | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.gitignore b/.gitignore index 071acdecb..94dcbebec 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,15 @@ authbridge/cmd/authbridge-proxy/authbridge-proxy authbridge/cmd/authbridge-envoy/authbridge-envoy authbridge/cmd/authbridge-lite/authbridge-lite authbridge/cmd/authbridge-cpex/authbridge-cpex +authbridge/cmd/authbridge-praxis/authbridge-praxis +# `go build ./cmd/...` from authbridge/ drops binaries in that dir rather than +# in the package dir, so ignore those spellings too. +authbridge/authbridge-proxy +authbridge/authbridge-envoy +authbridge/authbridge-cpex +authbridge/authbridge-praxis + +# MLflow local tracking store — created by `mlflow` runs in the working tree. +# Local experiment state, not project source. +mlflow.db +mlruns/ From 4c335004d097c61da4b0e4225a8044721f52c1ef Mon Sep 17 00:00:00 2001 From: Ed Snible Date: Fri, 21 Aug 2026 10:18:48 -0400 Subject: [PATCH 4/7] chore(authbridge): Add debug logging around config load and SPIFFE startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds slog.Debug lines to authbridge-proxy and authbridge-envoy covering the two things that are otherwise silent during boot: - that the config file was loaded, and from which path - whether the SPIFFE Provider is being constructed, against which socket, and when it succeeded — plus an explicit line for the 'config does not use SPIFFE' branch The provider case is the useful one. NewProvider blocks until the SPIRE Workload API returns the first SVID, so a misconfigured socket looks identical to a slow cold start: the binary simply hangs with no output. The before/after pair makes that distinguishable, and the else branch means 'no SPIFFE' is stated rather than inferred from absent logs. DEBUG level, so default INFO deployments are unaffected. Visible via LOG_LEVEL=debug, or by sending SIGUSR1 to a running pod to toggle the level without a restart. Assisted-By: Claude (Anthropic AI) Signed-off-by: Ed Snible --- authbridge/cmd/authbridge-envoy/main.go | 6 ++++++ authbridge/cmd/authbridge-proxy/main.go | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/authbridge/cmd/authbridge-envoy/main.go b/authbridge/cmd/authbridge-envoy/main.go index 1774694ad..12fb43afe 100644 --- a/authbridge/cmd/authbridge-envoy/main.go +++ b/authbridge/cmd/authbridge-envoy/main.go @@ -90,12 +90,15 @@ func main() { if err != nil { log.Fatalf("failed to load config %q: %v", *configPath, err) } + slog.Debug("config loaded", "configPath", *configPath) + var provider *spiffe.Provider if bootCfg.SPIFFE != nil { mirrorFiles := true if bootCfg.SPIFFE.MirrorFiles != nil { mirrorFiles = *bootCfg.SPIFFE.MirrorFiles } + slog.Debug("About to create SPIFFE Provider", "bootCfg.SPIFFE.Socket", bootCfg.SPIFFE.Socket) provider, err = spiffe.NewProvider(context.Background(), spiffe.ProviderConfig{ SocketPath: bootCfg.SPIFFE.Socket, MirrorFiles: mirrorFiles, @@ -105,6 +108,9 @@ func main() { log.Fatalf("spiffe provider: %v", err) } defer provider.Close() + slog.Debug("SPIFFE provider created", "bootCfg.SPIFFE.Socket", bootCfg.SPIFFE.Socket) + } else { + slog.Debug("Config does not use SPIFFE") } buildPipelines := func() (*pipeline.Pipeline, *pipeline.Pipeline, *config.Config, error) { diff --git a/authbridge/cmd/authbridge-proxy/main.go b/authbridge/cmd/authbridge-proxy/main.go index 4fcec77d6..a2e85b819 100644 --- a/authbridge/cmd/authbridge-proxy/main.go +++ b/authbridge/cmd/authbridge-proxy/main.go @@ -180,6 +180,8 @@ func main() { if err != nil { log.Fatalf("failed to load config %q: %v", *configPath, err) } + slog.Debug("config loaded", "configPath", *configPath) + // 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 @@ -195,6 +197,7 @@ func main() { if bootCfg.SPIFFE.MirrorFiles != nil { mirrorFiles = *bootCfg.SPIFFE.MirrorFiles } + slog.Debug("About to create SPIFFE Provider", "bootCfg.SPIFFE.Socket", bootCfg.SPIFFE.Socket) provider, err = spiffe.NewProvider(context.Background(), spiffe.ProviderConfig{ SocketPath: bootCfg.SPIFFE.Socket, MirrorFiles: mirrorFiles, @@ -204,9 +207,12 @@ func main() { log.Fatalf("spiffe provider: %v", err) } defer provider.Close() + slog.Debug("SPIFFE provider created", "bootCfg.SPIFFE.Socket", bootCfg.SPIFFE.Socket) } 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") + } else { + slog.Debug("Config does not use SPIFFE") } // This binary is hardcoded to proxy-sidecar. Rejecting other modes From 77c8eb849c65929786e9ffa9e356d1f1c9f9f1ef Mon Sep 17 00:00:00 2001 From: Ed Snible Date: Fri, 21 Aug 2026 12:54:45 -0400 Subject: [PATCH 5/7] fix(praxis): Close fail-open paths found in review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten review findings, the first several of which were fail-open in the direction that matters most: a generated proxy accepting traffic the AuthBridge config it came from would have rejected. Audience is now a hard error, not a warning (policy.go). TrustedIssuer's Audiences is `omitempty`, so an empty slice omitted the key entirely and the engine accepted ANY token from the trusted issuer — a fail-open weakening of the exact check jwt-validation exists to perform. A warning was the wrong instrument: it is advisory, and the policy still shipped. Both reachable paths are standard shapes, not edge cases (audience_file is the plugin's own default; audience_mode: per-host is the waypoint shape). Missing issuer and undecidable JWKS were already errors; audience now joins them. A test also pins that any emitted policy carries a non-empty audiences key, guarding the omitempty mechanism directly. Algorithms are no longer hardcoded to RS256. AuthBridge does not restrict algorithms at all — its verifier calls jwt.WithKeySet and accepts whatever the JWKS advertises — while the policy engine requires an explicit list. RS256-only meant an ES256 or RS512 realm got a policy rejecting every token AuthBridge accepted: silent total inbound outage. Now defaults to the asymmetric families, reports the default, and honors an `algorithms` field if the plugin ever grows one. HS* stays excluded (symmetric; invites algorithm confusion). The binary-backed test caught that ES512 is NOT a valid engine variant even though RS512/PS512 are — a bug in the first cut of this fix, now pinned against the real engine. JWKS URLs get a scheme allowlist. resolveJWKSURL concatenates raw strings, so a malformed keycloak_url yielded a URL that failed url.Parse, left insecureHTTP false, emitted no warning, and was still written to DecodingKey.URL — an undefined key source that reads as secure. Unparseable, non-http(s), and host-less URLs now fail conversion. transparent_proxy_addr and skip_hosts are now reported (praxis.go). The former is AuthBridge's hard egress guard, deliberately not self-exemptable, and the proxy-sidecar/lite presets default it on — so conversion silently removed a boundary the operator never explicitly enabled, contradicting this PR's stated contract that nothing is dropped silently. The policy filter is gated on the inbound direction. enforced[name] was direction-blind while BuildPolicy reads only inbound plugins, so an outbound jwt-validation emitted a policy filter pointing at an inbound-only document — enforcing the wrong stage's rules on egress and reporting the plugin as translated. Now covered by a test, as the reviewer noted it was not. A missing reverse_proxy_backend now warns. It previously skipped the inbound chain silently while main.go still logged "wrote Praxis policy enforces=[jwt-validation]" — asserting enforcement that did not exist. The transparent branch above it already warned; this restores symmetry. Writes are atomic and stale policies are removed (main.go). The nil-document branch left a previous run's policy in place, which entrypoint's `[ -s ]` check then announced as "policy engine will enforce it" for a file nothing loads. Both files now write via temp-file + fsync + rename, so Praxis — which watches its config and reloads on change — cannot observe a truncated document. The Dockerfile probe is now a positive assertion. It previously passed when output did NOT contain "unknown filter type", failing open on exactly the condition it guards: that string is an upstream format! with no stability guarantee and PRAXIS_REF is overridable, so any rewording would make the gate pass unconditionally. It now validates a complete, valid policy document and requires exit 0 — verified to exit 0 with policy-engine and 1 without. The git fetch fails closed. The all-branch-tips fallback plus `|| checkout FETCH_HEAD` meant a transient tag-fetch failure silently built main, undoing the reproducibility the comment above it promises. Each shape now checks out the ref it fetched, or the build stops. Also documents why rust:1.96-alpine is the one base image without a digest pin: a digest names a single architecture's manifest, and this is the stage that must build from source on both arm64 and amd64. Assisted-By: Claude (Anthropic AI) Signed-off-by: Ed Snible --- authbridge/authlib/praxis/policy.go | 135 +++++++++-- authbridge/authlib/praxis/policy_test.go | 242 ++++++++++++++++++-- authbridge/authlib/praxis/praxis.go | 65 +++++- authbridge/authlib/praxis/praxis_test.go | 48 ++++ authbridge/cmd/authbridge-praxis/Dockerfile | 100 ++++++-- authbridge/cmd/authbridge-praxis/main.go | 70 +++++- 6 files changed, 589 insertions(+), 71 deletions(-) diff --git a/authbridge/authlib/praxis/policy.go b/authbridge/authlib/praxis/policy.go index 23e49fcfe..97632b371 100644 --- a/authbridge/authlib/praxis/policy.go +++ b/authbridge/authlib/praxis/policy.go @@ -137,6 +137,43 @@ type jwtValidationConfig struct { AudienceMode string `json:"audience_mode"` AllowedAudiences []string `json:"allowed_audiences"` BypassPaths []string `json:"bypass_paths"` + // Algorithms is not a field jwt-validation defines today; it is read here + // so that if the plugin gains one, the generated policy honors it instead + // of silently keeping this converter's default. See [defaultJWTAlgorithms] + // for why a default is needed at all. + Algorithms []string `json:"algorithms"` +} + +// defaultJWTAlgorithms is the algorithm set the generated policy accepts when +// the AuthBridge config names none. +// +// AuthBridge's verifier does not restrict algorithms: it calls +// jwt.Parse(..., jwt.WithKeySet(keySet)) and accepts whatever the JWKS key +// advertises (see authlib/plugins/jwtvalidation/validation/jwks.go). The policy +// engine, by contrast, REQUIRES a non-empty algorithms list per trusted issuer +// and verifies only those. +// +// So there is no way to express "whatever the JWKS says" in the policy, and any +// list this converter picks is narrower than AuthBridge. Picking RS256 alone — +// the previous behavior — means an ES256 or RS512 realm gets a policy that +// rejects every token AuthBridge accepted: a total inbound outage, with nothing +// in the config to hint at why. Enumerating the asymmetric families Keycloak +// and other OIDC providers actually sign with keeps the common cases working. +// +// HMAC (HS*) is deliberately excluded: those are symmetric, cannot be served +// over JWKS as a verification key in this shape, and accepting them alongside +// an asymmetric issuer invites algorithm-confusion attacks. +// +// Every entry must be a variant the policy engine's Algorithm enum accepts, or +// it rejects the whole document at startup ("unknown variant `X`, expected one +// of ..."). Upstream's asymmetric set is exactly these nine — note ES512 is NOT +// among them, though RS512 and PS512 are. TestBuildPolicy_DefaultAlgorithms_* +// and the binary-backed policy test pin this against the real engine. +var defaultJWTAlgorithms = []string{ + "RS256", "RS384", "RS512", + "PS256", "PS384", "PS512", + "ES256", "ES384", + "EdDSA", } // resolveJWKSURL reproduces jwt-validation's derivation priority so the @@ -272,14 +309,40 @@ func jwtPolicyPlugin(entry config.PluginEntry) (PolicyPlugin, []string, error) { var warnings []string + // The JWKS URL must be a well-formed http:// or https:// URL, and nothing + // else, before it is written into the policy. + // + // resolveJWKSURL builds this by string concatenation from issuer / + // keycloak_url, so a malformed keycloak_url produces a malformed result. If + // that were merely parsed-and-ignored, insecure_http would stay false and no + // warning would fire, while the garbage URL was still written to + // DecodingKey.URL — a key source that is undefined at runtime but reads as + // secure in the file. Requiring a recognized scheme up front turns that into + // a startup-time conversion error instead. + u, parseErr := url.Parse(jwks) + switch { + case parseErr != nil: + return PolicyPlugin{}, nil, fmt.Errorf( + "praxis: jwt-validation JWKS endpoint %q is not a valid URL: %w (derived from "+ + "jwks_url, or keycloak_url + keycloak_realm — check those for typos)", + jwks, parseErr) + case u.Scheme != "http" && u.Scheme != "https": + return PolicyPlugin{}, nil, fmt.Errorf( + "praxis: jwt-validation JWKS endpoint %q has scheme %q; the policy engine fetches "+ + "JWKS over http or https only. A missing scheme usually means keycloak_url was "+ + "set without one", jwks, u.Scheme) + case u.Host == "": + return PolicyPlugin{}, nil, fmt.Errorf( + "praxis: jwt-validation JWKS endpoint %q has no host", jwks) + } + // The policy engine rejects a plaintext http:// JWKS URL unless // insecure_http is set. AuthBridge does not have this guard, so a local / // demo config that works under AuthBridge would fail Praxis startup // outright. Set the flag to preserve behavior, and say so — over plaintext // anyone on the path can swap the key material and forge accepted JWTs. - insecureHTTP := false - if u, err := url.Parse(jwks); err == nil && u.Scheme == "http" { - insecureHTTP = true + insecureHTTP := u.Scheme == "http" + if insecureHTTP { warnings = append(warnings, fmt.Sprintf( "jwt-validation JWKS endpoint %q is plaintext http://, so the generated policy sets "+ "decoding_key.insecure_http: true (the policy engine rejects http:// JWKS URLs "+ @@ -288,29 +351,59 @@ func jwtPolicyPlugin(entry config.PluginEntry) (PolicyPlugin, []string, error) { "development; use https for anything else.", jwks)) } + // Algorithms: honor the plugin's list if it ever grows one, else default to + // the asymmetric families. See [defaultJWTAlgorithms] for why the default + // cannot be a single algorithm. + algs := jc.Algorithms + if len(algs) == 0 { + algs = defaultJWTAlgorithms + warnings = append(warnings, fmt.Sprintf( + "jwt-validation names no signing algorithms (it does not restrict them: its verifier "+ + "accepts whatever the JWKS key advertises), but the policy engine requires an "+ + "explicit list per issuer. The generated policy accepts %v. If issuer %q signs "+ + "with something outside that set, every token will be rejected — set "+ + "trusted_issuers[0].algorithms in the generated policy to match the realm's keys.", + algs, jc.Issuer)) + } + + // Audience resolution FAILS the conversion when it yields nothing. + // + // This is deliberately an error rather than a warning. Upstream treats an + // empty `audiences` list as "disable aud validation", and TrustedIssuer's + // Audiences field is `omitempty` — so an empty slice does not emit an empty + // list, it omits the key entirely and the engine accepts ANY token from the + // trusted issuer. That is a fail-open weakening of the exact check + // jwt-validation exists to perform, and a warning is the wrong instrument + // for it: warnings are advisory, and the generated policy would still be + // deployed, silently authenticating tokens minted for a different audience. + // + // Both reachable paths here are standard AuthBridge shapes, not edge cases: + // `audience_file` is the Rossoctl client-id convention and the plugin's own + // default, and `audience_mode: per-host` is the waypoint shape. Missing + // issuer and undecidable JWKS are already hard errors above; audience + // belongs with them. auds := jc.audiences() if len(auds) == 0 { - // Upstream treats an empty audiences list as "disable aud validation". - // AuthBridge always validates audience, so an empty list here is a - // real weakening and must be reported rather than quietly emitted. switch { case jc.AudienceMode == "per-host": - warnings = append(warnings, - "jwt-validation uses audience_mode: per-host, which derives the expected "+ - "audience from each request's Host. The policy engine's trusted_issuers take "+ - "a static audience list, so the generated policy does NOT validate the aud "+ - "claim. Add the concrete audiences to trusted_issuers[0].audiences.") + return PolicyPlugin{}, nil, fmt.Errorf( + "praxis: jwt-validation uses audience_mode: per-host, which derives the expected " + + "audience from each request's Host; the policy engine's trusted_issuers take a " + + "static audience list and cannot express that. Generating a policy anyway " + + "would omit the audiences key entirely and accept ANY token from the issuer. " + + "Set an explicit `audience` (or `allowed_audiences`) on the plugin to convert") case jc.AudienceFile != "": - warnings = append(warnings, fmt.Sprintf( - "jwt-validation reads its expected audience from the file %q at runtime, which "+ - "this converter does not read. The generated policy does NOT validate the "+ - "aud claim — any token from the trusted issuer is accepted regardless of "+ - "audience. Set `audience` explicitly in the plugin config, or add the value "+ - "to trusted_issuers[0].audiences in the generated policy.", jc.AudienceFile)) + return PolicyPlugin{}, nil, fmt.Errorf( + "praxis: jwt-validation reads its expected audience from the file %q at runtime, "+ + "which this converter does not read; generating a policy anyway would omit the "+ + "audiences key entirely and accept ANY token from issuer %q. Set an explicit "+ + "`audience` (or `allowed_audiences`) on the plugin to convert", + jc.AudienceFile, jc.Issuer) default: - warnings = append(warnings, - "jwt-validation declares no audience, so the generated policy does NOT validate "+ - "the aud claim: any token from the trusted issuer is accepted.") + return PolicyPlugin{}, nil, fmt.Errorf( + "praxis: jwt-validation declares no audience, so no policy could be generated "+ + "that validates the aud claim; without it any token from issuer %q would be "+ + "accepted. Set `audience` or `allowed_audiences` on the plugin", jc.Issuer) } } @@ -341,7 +434,7 @@ func jwtPolicyPlugin(entry config.PluginEntry) (PolicyPlugin, []string, error) { TrustedIssuers: []TrustedIssuer{{ Issuer: jc.Issuer, Audiences: auds, - Algorithms: []string{"RS256"}, + Algorithms: algs, DecodingKey: DecodingKey{ Kind: "jwks_url", URL: jwks, diff --git a/authbridge/authlib/praxis/policy_test.go b/authbridge/authlib/praxis/policy_test.go index 45cedd8ff..cbcde304e 100644 --- a/authbridge/authlib/praxis/policy_test.go +++ b/authbridge/authlib/praxis/policy_test.go @@ -216,42 +216,168 @@ func TestBuildPolicy_AudienceUnion(t *testing.T) { } // An audience_file is read at runtime by AuthBridge and cannot be resolved -// here. The resulting policy does NOT validate aud, which is a real weakening -// and must be reported rather than passing silently. -func TestBuildPolicy_AudienceFile_WarnsAboutNoAudValidation(t *testing.T) { - res, err := BuildPolicy(proxySidecar(t, func(c *config.Config) { +// here. Emitting the plugin anyway is FAIL-OPEN: Audiences is omitempty, so an +// empty slice omits the key and the engine accepts any token from the issuer. +// That must be an error, not a warning — a warning still ships the policy. +func TestBuildPolicy_AudienceFile_IsError(t *testing.T) { + _, err := BuildPolicy(proxySidecar(t, func(c *config.Config) { c.Pipeline.Inbound.Plugins = []config.PluginEntry{jwtPlugin(t, map[string]any{ "issuer": "https://idp.example.com/realms/r", "audience_file": "/shared/client-id.txt", })} })) + if err == nil { + t.Fatal("expected an error: audience_file cannot be resolved, so aud would go unvalidated") + } + if !strings.Contains(err.Error(), "/shared/client-id.txt") { + t.Errorf("error should name the file: %v", err) + } +} + +// audience_mode: per-host is the waypoint shape and equally unrepresentable in +// a static trusted_issuers list, so it fails for the same fail-open reason. +func TestBuildPolicy_PerHostAudience_IsError(t *testing.T) { + _, err := BuildPolicy(proxySidecar(t, func(c *config.Config) { + c.Pipeline.Inbound.Plugins = []config.PluginEntry{jwtPlugin(t, map[string]any{ + "issuer": "https://idp.example.com/realms/r", + "audience_mode": "per-host", + })} + })) + if err == nil { + t.Fatal("expected an error: per-host audience cannot be expressed statically") + } + if !strings.Contains(err.Error(), "per-host") { + t.Errorf("error should mention per-host: %v", err) + } +} + +// No audience of any kind is the third fail-open path. +func TestBuildPolicy_NoAudience_IsError(t *testing.T) { + _, err := BuildPolicy(proxySidecar(t, func(c *config.Config) { + c.Pipeline.Inbound.Plugins = []config.PluginEntry{jwtPlugin(t, map[string]any{ + "issuer": "https://idp.example.com/realms/r", + })} + })) + if err == nil { + t.Fatal("expected an error when no audience is declared") + } +} + +// Guard the fail-open mechanism directly: if Audiences is ever populated empty, +// omitempty drops the key and the engine stops validating aud. Any policy this +// converter emits must carry at least one audience. +func TestBuildPolicy_EmittedPolicyAlwaysHasAudiences(t *testing.T) { + res, err := BuildPolicy(proxySidecar(t, func(c *config.Config) { + c.Pipeline.Inbound.Plugins = []config.PluginEntry{keycloakJWT(t)} + })) + if err != nil { + t.Fatalf("BuildPolicy: %v", err) + } + for _, p := range res.Document.Plugins { + jc, ok := p.Config.(JWTIdentityConfig) + if !ok { + continue + } + for i, ti := range jc.TrustedIssuers { + if len(ti.Audiences) == 0 { + t.Errorf("plugin %q trusted_issuers[%d] has no audiences; omitempty would drop "+ + "the key and the engine would accept any token from the issuer", p.Name, i) + } + } + } + + // And confirm the rendered YAML actually carries the key. + out, err := RenderPolicyResult(res, "/tmp/c.yaml") + if err != nil { + t.Fatalf("RenderPolicyResult: %v", err) + } + if !strings.Contains(string(out), "audiences:") { + t.Errorf("rendered policy must carry an audiences key:\n%s", out) + } +} + +// AuthBridge's verifier does not restrict algorithms (jwt.WithKeySet accepts +// whatever the JWKS key advertises), but the policy engine requires an explicit +// list. Defaulting to RS256 alone would reject every token from an ES256 or +// RS512 realm — a total inbound outage. The default must cover the asymmetric +// families and be reported. +func TestBuildPolicy_DefaultAlgorithms(t *testing.T) { + res, err := BuildPolicy(proxySidecar(t, func(c *config.Config) { + c.Pipeline.Inbound.Plugins = []config.PluginEntry{keycloakJWT(t)} + })) if err != nil { t.Fatalf("BuildPolicy: %v", err) } jc := res.Document.Plugins[0].Config.(JWTIdentityConfig) - if len(jc.TrustedIssuers[0].Audiences) != 0 { - t.Errorf("expected no audiences, got %v", jc.TrustedIssuers[0].Audiences) + algs := jc.TrustedIssuers[0].Algorithms + for _, want := range []string{"RS256", "RS512", "ES256", "PS256", "EdDSA"} { + found := false + for _, a := range algs { + if a == want { + found = true + } + } + if !found { + t.Errorf("default algorithms %v missing %q", algs, want) + } } - if !containsSubstring(res.Warnings, "does NOT validate") { - t.Errorf("expected a warning that aud is unvalidated, got %v", res.Warnings) + // HS* is symmetric and cannot be a JWKS verification key here; including it + // alongside an asymmetric issuer invites algorithm confusion. + for _, a := range algs { + if strings.HasPrefix(a, "HS") { + t.Errorf("default algorithms must not include symmetric %q: %v", a, algs) + } } - if !containsSubstring(res.Warnings, "/shared/client-id.txt") { - t.Errorf("expected the warning to name the file, got %v", res.Warnings) + if !containsSubstring(res.Warnings, "algorithms") { + t.Errorf("defaulting the algorithm list must be reported, got %v", res.Warnings) } } -func TestBuildPolicy_PerHostAudience_Warns(t *testing.T) { +// An explicit algorithms list on the plugin must win over the default, so the +// generated policy tracks the realm rather than this converter's guess. +func TestBuildPolicy_ExplicitAlgorithmsWin(t *testing.T) { res, err := BuildPolicy(proxySidecar(t, func(c *config.Config) { c.Pipeline.Inbound.Plugins = []config.PluginEntry{jwtPlugin(t, map[string]any{ - "issuer": "https://idp.example.com/realms/r", - "audience_mode": "per-host", + "issuer": "https://idp.example.com/realms/r", + "audience": "svc", + "algorithms": []string{"ES384"}, })} })) if err != nil { t.Fatalf("BuildPolicy: %v", err) } - if !containsSubstring(res.Warnings, "per-host") { - t.Errorf("expected a per-host warning, got %v", res.Warnings) + jc := res.Document.Plugins[0].Config.(JWTIdentityConfig) + algs := jc.TrustedIssuers[0].Algorithms + if len(algs) != 1 || algs[0] != "ES384" { + t.Errorf("algorithms = %v, want [ES384]", algs) + } + if containsSubstring(res.Warnings, "names no signing algorithms") { + t.Errorf("an explicit list must not warn about defaulting: %v", res.Warnings) + } +} + +// A malformed JWKS URL must fail rather than being written into the policy: it +// would be an undefined key source that reads as secure, since insecure_http +// stays false and no warning fires. +func TestBuildPolicy_MalformedJWKS_IsError(t *testing.T) { + for _, tc := range []struct{ name, keycloakURL string }{ + {"no scheme", "keycloak.example.com:8080"}, + {"unsupported scheme", "ftp://keycloak.example.com"}, + {"scheme only", "https://"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := BuildPolicy(proxySidecar(t, func(c *config.Config) { + c.Pipeline.Inbound.Plugins = []config.PluginEntry{jwtPlugin(t, map[string]any{ + "issuer": "https://idp.example.com/realms/r", + "audience": "svc", + "keycloak_url": tc.keycloakURL, + "keycloak_realm": "r", + })} + })) + if err == nil { + t.Errorf("expected an error for keycloak_url %q", tc.keycloakURL) + } + }) } } @@ -410,6 +536,81 @@ func TestConvertWithPolicy_SinglePolicyFilterPerChain(t *testing.T) { } } +// BuildPolicy reads only the INBOUND pipeline, so an outbound jwt-validation +// entry must NOT produce a `policy` filter on the outbound chain: that filter +// would point at a document describing inbound identity, enforcing the wrong +// stage's rules on egress while reporting the plugin as translated. +func TestConvertWithPolicy_OutboundJWTDoesNotEmitPolicyFilter(t *testing.T) { + cfg := proxySidecar(t, func(c *config.Config) { + c.Pipeline.Inbound.Plugins = []config.PluginEntry{keycloakJWT(t)} + // Same plugin name, wrong direction. + c.Pipeline.Outbound.Plugins = []config.PluginEntry{keycloakJWT(t)} + }) + res, pol, err := ConvertWithPolicy(cfg, "/tmp/praxis-policy.yaml") + if err != nil { + t.Fatalf("ConvertWithPolicy: %v", err) + } + if pol.Document == nil { + t.Fatal("expected a policy document from the inbound plugin") + } + + byChain := map[string]int{} + for _, ch := range res.Config.FilterChains { + for _, f := range ch.Filters { + if f.Type == "policy" { + byChain[ch.Name]++ + } + } + } + if byChain[ChainInbound] != 1 { + t.Errorf("inbound chain policy filters = %d, want 1", byChain[ChainInbound]) + } + if byChain[ChainOutbound] != 0 { + t.Errorf("outbound chain must carry no policy filter (the document is inbound-only), got %d", + byChain[ChainOutbound]) + } + // The outbound entry is not enforced by anything, so it must be reported. + if !containsSubstring(res.Unmapped, "jwt-validation") { + t.Errorf("an outbound jwt-validation is unenforced and must be reported unmapped: %v", + res.Unmapped) + } +} + +// A policy document is written but no inbound listener can be generated, so +// nothing loads it. That must be surfaced, or the caller logs +// "wrote Praxis policy, enforces=[jwt-validation]" for enforcement that does +// not exist. +func TestConvertWithPolicy_NoBackend_WarnsPolicyNotEnforced(t *testing.T) { + cfg := &config.Config{ + Mode: config.ModeProxySidecar, + Listener: config.ListenerConfig{ + Roles: []string{config.RoleReverse, config.RoleForward}, + // No reverse_proxy_backend. + }, + } + cfg.Pipeline.Inbound.Plugins = []config.PluginEntry{keycloakJWT(t)} + config.ApplyPreset(cfg) + + res, pol, err := ConvertWithPolicy(cfg, "/tmp/praxis-policy.yaml") + if err != nil { + t.Fatalf("ConvertWithPolicy: %v", err) + } + if pol.Document == nil { + t.Fatal("expected a policy document (the inbound plugin is present)") + } + for _, l := range res.Config.Listeners { + if l.Name == ListenerInbound { + t.Fatal("expected no inbound listener without a backend") + } + } + if !containsSubstring(res.Warnings, "reverse_proxy_backend") { + t.Errorf("expected a warning naming the missing field, got %v", res.Warnings) + } + if !containsSubstring(res.Warnings, "not enforced") { + t.Errorf("expected the warning to say inbound plugins are unenforced, got %v", res.Warnings) + } +} + func TestRenderPolicyResult_ParsesAsYAML(t *testing.T) { pol, err := BuildPolicy(proxySidecar(t, func(c *config.Config) { c.Pipeline.Inbound.Plugins = []config.PluginEntry{keycloakJWT(t)} @@ -498,10 +699,15 @@ func TestGeneratedPolicy_ValidatesWithPraxis(t *testing.T) { })}, }, { - name: "no audience (aud validation disabled)", + // Pins that every entry of defaultJWTAlgorithms is a variant the + // engine's Algorithm enum accepts. An invalid one (ES512, which + // upstream does NOT accept despite RS512/PS512 being valid) makes + // the engine reject the whole document at startup. + name: "default algorithm set is accepted by the engine", plugins: []config.PluginEntry{jwtPlugin(t, map[string]any{ - "issuer": "https://idp.example.com/realms/r", - "audience_file": "/shared/client-id.txt", + "issuer": "https://idp.example.com/realms/r", + "jwks_url": "https://idp.example.com/realms/r/protocol/openid-connect/certs", + "audience": "svc", })}, }, { diff --git a/authbridge/authlib/praxis/praxis.go b/authbridge/authlib/praxis/praxis.go index 04ddb54fc..afdf7025b 100644 --- a/authbridge/authlib/praxis/praxis.go +++ b/authbridge/authlib/praxis/praxis.go @@ -457,6 +457,41 @@ func Convert(cfg *config.Config, opts *Options) (*Result, error) { "Praxis requires at least one listener", cfg.Mode, cfg.Listener.Roles) } + // ── Outbound listener fields with no Praxis counterpart ────────────── + // Reported outside the forward-role branch above because both fields are + // set independently of it, and because silence here is the failure mode + // that matters: each one removes an enforcement boundary. + if cfg.Listener.TransparentProxyAddr != "" { + // The outbound transparent listener is AuthBridge's HARD egress guard: + // iptables REDIRECTs the agent's bypass egress to it, and unlike + // skip_hosts it is deliberately not self-exemptable by the agent's + // chosen destination. The proxy-sidecar and lite presets default it to + // :8082, so it is effectively always on for those shapes — which means + // converting such a config silently removes a guard the operator never + // explicitly enabled and so may not think to check for. + res.Warnings = append(res.Warnings, fmt.Sprintf( + "listener.transparent_proxy_addr (%s) has no Praxis counterpart and is NOT translated: "+ + "it is AuthBridge's hard egress guard, receiving iptables-REDIRECTed traffic and "+ + "recovering the original destination via SO_ORIGINAL_DST — a mechanism Praxis, which "+ + "routes to preconfigured clusters, cannot express. Egress that AuthBridge forced "+ + "through the outbound pipeline is unconstrained by the generated config. Note the "+ + "proxy-sidecar and lite presets set this by default, so it may be active without "+ + "appearing in the source config.", cfg.Listener.TransparentProxyAddr)) + } + if len(cfg.Listener.SkipHosts) > 0 { + // Direction of risk is the opposite of the guard above: skip_hosts + // makes AuthBridge MORE permissive, so not translating it is not a + // security regression. It still changes behavior — those hosts now run + // the generated chain — and it silently drops the operator's intent. + res.Warnings = append(res.Warnings, fmt.Sprintf( + "listener.skip_hosts %v is NOT translated: AuthBridge bypasses the plugin pipeline and "+ + "session recording entirely for these destinations. The generated config has no "+ + "equivalent bypass, so traffic to them runs the outbound chain like any other. This "+ + "is more restrictive, not less — but if these are infrastructure destinations that "+ + "must not be subject to egress policy, express that with router routes instead.", + cfg.Listener.SkipHosts)) + } + // ── Top-level blocks ───────────────────────────────────────────────── res.Config.Admin, res.Config.InsecureOptions = convertAdmin(cfg) if cfg.TLSBridge != nil && cfg.TLSBridge.Mode == "enabled" { @@ -493,7 +528,26 @@ func inboundAddrAndBackend(cfg *config.Config, res *Result) (addr, backend strin "address to emit one.") return "", "", nil } + // Without both an address to bind and a backend to forward to there is no + // inbound listener to generate. Warn rather than returning silently: the + // inbound chain is where the `policy` filter lives, so dropping it drops + // JWT enforcement — and BuildPolicy still produced a document, so the + // caller would otherwise log "wrote Praxis policy, enforces=[jwt-validation]" + // for a policy no generated listener loads. The transparent branch above + // warns for the same reason; this keeps the two symmetric. if cfg.Listener.ReverseProxyAddr == "" || cfg.Listener.ReverseProxyBackend == "" { + missing := "listener.reverse_proxy_backend" + if cfg.Listener.ReverseProxyAddr == "" { + missing = "listener.reverse_proxy_addr" + if cfg.Listener.ReverseProxyBackend == "" { + missing = "listener.reverse_proxy_addr and listener.reverse_proxy_backend" + } + } + res.Warnings = append(res.Warnings, fmt.Sprintf( + "the reverse role is active but %s is empty, so NO inbound listener was generated. "+ + "Any inbound plugins — including jwt-validation — are therefore not enforced by "+ + "the generated config, even if a policy document was written for them. Set the "+ + "missing field to emit the inbound listener.", missing)) return "", "", nil } endpoint, err := endpointFromBackendURL(cfg.Listener.ReverseProxyBackend) @@ -788,8 +842,17 @@ func pluginFilters(plugins []config.PluginEntry, dir direction, opts *Options) ( // Plugins the generated policy document enforces. Those become a single // `policy` filter at the position of the first one, rather than an inert // marker — the policy engine runs them all from one document. + // + // Gated on the INBOUND direction: BuildPolicy reads only + // cfg.Pipeline.Inbound.Plugins, so the document it produces describes + // inbound identity alone. Matching on name irrespective of direction would + // let an outbound jwt-validation entry emit a `policy` filter on the + // outbound chain pointing at a document built from inbound plugins — + // enforcing the wrong stage's rules on egress, and reporting the plugin as + // translated when nothing in the document corresponds to it. If the policy + // ever grows outbound plugins, this gate is what must change with it. enforced := map[string]bool{} - if opts != nil && opts.PolicyPath != "" { + if opts != nil && opts.PolicyPath != "" && dir == directionInbound { for _, n := range opts.PolicyEnforces { enforced[n] = true } diff --git a/authbridge/authlib/praxis/praxis_test.go b/authbridge/authlib/praxis/praxis_test.go index 5d30e7fd7..3479ccb23 100644 --- a/authbridge/authlib/praxis/praxis_test.go +++ b/authbridge/authlib/praxis/praxis_test.go @@ -373,6 +373,54 @@ func TestConvert_NoMTLS_NoSVIDWarning(t *testing.T) { } } +// The outbound transparent listener is AuthBridge's hard egress guard — +// deliberately not self-exemptable — and the proxy-sidecar preset defaults it +// on. Dropping it silently would remove an enforcement boundary the operator +// never explicitly enabled. +func TestConvert_TransparentProxyAddr_Reported(t *testing.T) { + res, err := Convert(proxySidecar(t, nil), nil) + if err != nil { + t.Fatalf("Convert: %v", err) + } + // The preset fills this for proxy-sidecar, so it should be reported without + // the test setting it explicitly. + if got := res.Config.Listeners; len(got) == 0 { + t.Fatal("expected listeners") + } + if !containsSubstring(res.Warnings, "transparent_proxy_addr") { + t.Errorf("expected transparent_proxy_addr reported, got %v", res.Warnings) + } + if !containsSubstring(res.Warnings, "egress guard") { + t.Errorf("the warning should say what is lost, got %v", res.Warnings) + } +} + +func TestConvert_SkipHosts_Reported(t *testing.T) { + res, err := Convert(proxySidecar(t, func(c *config.Config) { + c.Listener.SkipHosts = []string{"otel-collector*", "*.metrics.local"} + }), nil) + if err != nil { + t.Fatalf("Convert: %v", err) + } + if !containsSubstring(res.Warnings, "skip_hosts") { + t.Errorf("expected skip_hosts reported, got %v", res.Warnings) + } + if !containsSubstring(res.Warnings, "otel-collector*") { + t.Errorf("the warning should name the patterns, got %v", res.Warnings) + } +} + +// No skip_hosts configured means nothing to report about them. +func TestConvert_NoSkipHosts_NotReported(t *testing.T) { + res, err := Convert(proxySidecar(t, nil), nil) + if err != nil { + t.Fatalf("Convert: %v", err) + } + if containsSubstring(res.Warnings, "skip_hosts") { + t.Errorf("skip_hosts is unset and must not be reported: %v", res.Warnings) + } +} + // Transparent inbound interception has no Praxis counterpart: Praxis cannot // recover the original destination per connection. It must be reported rather // than silently producing a listener pointed somewhere invented. diff --git a/authbridge/cmd/authbridge-praxis/Dockerfile b/authbridge/cmd/authbridge-praxis/Dockerfile index 3a3d6b808..d5636eaa4 100644 --- a/authbridge/cmd/authbridge-praxis/Dockerfile +++ b/authbridge/cmd/authbridge-praxis/Dockerfile @@ -30,6 +30,18 @@ # its rust-toolchain.toml) plus the musl / OpenSSL / cmake toolchain its # dependency tree needs. OPENSSL_STATIC links OpenSSL into the binary so the # runtime stage needs no OpenSSL package. +# +# Deliberately NOT digest-pinned, unlike the golang and alpine images below. A +# digest names one architecture's manifest, so pinning here would break +# multi-arch builds of this image — and this is the one stage that must build on +# both arm64 and amd64 from source. The tag is a minor-version tag +# (rust:1.96-alpine, not :latest or :1), so the toolchain version is still +# fixed; what floats is only the patch/base-image refresh, which is what keeps +# the multi-arch manifest usable. The build's reproducibility guarantee comes +# from PRAXIS_REF below, which pins the source actually being compiled. +# +# The runtime stages ARE digest-pinned: they ship the final artifacts, so +# supply-chain pinning matters more there than cross-arch flexibility. FROM rust:1.96-alpine AS praxis-builder ENV OPENSSL_STATIC=1 @@ -45,18 +57,27 @@ ARG PRAXIS_REF=v0.5.3 WORKDIR /praxis -# Fetch the pinned ref without cloning full history. Three shapes of -# PRAXIS_REF are accepted, tried cheapest-first: a tag (the default), then a -# bare SHA or branch via a direct refspec fetch, then all branch tips as a last -# resort. The tag path is listed first because fetching an arbitrary commit by -# SHA requires the server to allow it, which GitHub does not always do — so a -# SHA-pinned build can fall through to the later forms. +# Fetch exactly PRAXIS_REF, or fail. +# +# Two shapes are accepted — a tag (the default) or a bare SHA / branch — and each +# is checked out from the ref it just fetched. That coupling is the point: an +# earlier version fell back to fetching ALL branch tips and checking out +# FETCH_HEAD, so a transient failure fetching the pinned tag would silently build +# main instead — defeating the reproducibility the PRAXIS_REF comment above +# promises, on exactly the condition it was meant to guarantee. Failing the build +# is the only safe outcome; a stage that quietly compiles different source than +# it was told to is worse than one that stops. +# +# Both shapes are attempted because fetching an arbitrary commit by SHA requires +# the server to permit it, which GitHub does not always do: a SHA-pinned build +# needs the second form, a tag-pinned build the first. RUN git init -q . \ && git remote add origin "${PRAXIS_REPO}" \ - && ( git fetch -q --depth 1 origin "refs/tags/${PRAXIS_REF}:refs/tags/${PRAXIS_REF}" \ - || git fetch -q --depth 1 origin "${PRAXIS_REF}" \ - || git fetch -q --depth 1 origin '+refs/heads/*:refs/remotes/origin/*' ) \ - && ( git checkout -q "${PRAXIS_REF}" || git checkout -q FETCH_HEAD ) \ + && ( ( git fetch -q --depth 1 origin "refs/tags/${PRAXIS_REF}:refs/tags/${PRAXIS_REF}" \ + && git checkout -q "refs/tags/${PRAXIS_REF}" ) \ + || ( git fetch -q --depth 1 origin "${PRAXIS_REF}" \ + && git checkout -q FETCH_HEAD ) \ + || { echo "FATAL: could not fetch PRAXIS_REF=${PRAXIS_REF} from ${PRAXIS_REPO}" >&2; exit 1; } ) \ && git --no-pager log --oneline -1 # `policy-engine` pulls in the praxis-policy crates (identity/jwt, Cedar PDP, @@ -66,16 +87,43 @@ RUN cargo build --release -p praxis-proxy --features policy-engine \ && cp target/release/praxis /praxis-bin \ && strip /praxis-bin -# Fail the build here rather than at container start if the feature flag did -# not take effect: a Praxis without the `policy` filter cannot run the configs -# this image generates. A missing config_path proves the filter is registered -# (an unregistered filter reports "unknown filter type" instead). +# Fail the build here rather than at container start if the policy-engine +# feature did not take effect: a Praxis without the `policy` filter cannot run +# the configs this image generates, and that failure would otherwise surface as +# a crashlooping pod. +# +# The assertion is POSITIVE — a complete, valid policy document plus a config +# that references it must make `--validate` exit 0. An earlier version instead +# probed with a deliberately-missing config_path and passed when the output did +# NOT contain "unknown filter type". That inverted form fails open on precisely +# the condition it guards: the string is an upstream `format!` with no stability +# guarantee, and PRAXIS_REF is overridable, so any rewording of that message +# would make the gate pass unconditionally — silently shipping a Praxis that +# rejects every generated config. Exit status cannot drift the same way. # -# The probe is EXPECTED to exit non-zero (its config_path is deliberately -# absent), so its output is redirected to a file and inspected rather than -# piped — a pipeline would either mask the status or, under pipefail, abort -# before the grep that actually decides the outcome. -RUN printf 'listeners:\n\ +# The probe policy mirrors what BuildPolicy emits (an identity/jwt plugin over +# trusted_issuers), so this also catches an upstream change to the policy schema +# that the generator has not been updated for. HS256 with an inline secret keeps +# it self-contained: no JWKS fetch, so no network dependency at build time. +RUN printf 'plugins:\n\ + - name: probe-jwt\n\ + kind: identity/jwt\n\ + hooks: [identity.resolve]\n\ + mode: sequential\n\ + priority: 10\n\ + on_error: fail\n\ + config:\n\ + header: Authorization\n\ + claim_mapper: standard\n\ + trusted_issuers:\n\ + - issuer: "https://probe.invalid/realms/probe"\n\ + audiences: ["probe-aud"]\n\ + algorithms: ["HS256"]\n\ + decoding_key:\n\ + kind: secret\n\ + secret: "build-time-probe-secret-not-used-at-runtime"\n\ + leeway_seconds: 60\n' > /probe-policy.yaml \ + && printf 'listeners:\n\ - name: probe\n\ address: "127.0.0.1:18099"\n\ filter_chains: [c]\n\ @@ -83,15 +131,17 @@ filter_chains:\n\ - name: c\n\ filters:\n\ - filter: policy\n\ - config_path: /nonexistent/policy.yaml\n\ + config_path: /probe-policy.yaml\n\ + require_protocol_metadata: false\n\ - filter: static_response\n\ status: 200\n' > /probe.yaml \ - && { /praxis-bin --validate --config /probe.yaml > /probe.out 2>&1 || true; } \ - && cat /probe.out \ - && if grep -q "unknown filter type" /probe.out; then \ - echo "FATAL: praxis was built WITHOUT the policy-engine feature" >&2; exit 1; \ + && if ! /praxis-bin --validate --config /probe.yaml; then \ + echo "FATAL: praxis rejected a policy-filter config — it was probably built WITHOUT" >&2; \ + echo " --features policy-engine, or the policy schema has changed upstream." >&2; \ + exit 1; \ fi \ - && rm -f /probe.yaml /probe.out + && echo "policy-engine verified: policy filter accepted a valid policy document" \ + && rm -f /probe.yaml /probe-policy.yaml # ── Stage 2: Build the authbridge-praxis config generator ─────────────────── # diff --git a/authbridge/cmd/authbridge-praxis/main.go b/authbridge/cmd/authbridge-praxis/main.go index 4c5b004f6..901a9192e 100644 --- a/authbridge/cmd/authbridge-praxis/main.go +++ b/authbridge/cmd/authbridge-praxis/main.go @@ -10,6 +10,7 @@ import ( "log" "log/slog" "os" + "path/filepath" "github.com/rossoctl/cortex/authbridge/authlib/config" "github.com/rossoctl/cortex/authbridge/authlib/praxis" @@ -233,15 +234,24 @@ func writePraxisConfig(cfg *config.Config, outPath, policyPath string) error { if err != nil { return fmt.Errorf("rendering policy: %w", err) } - // 0o644: the policy references a JWKS URL and issuer/audience values, - // never key material or secrets, and Praxis may run as another user. - if err := os.WriteFile(policyPath, policyData, 0o644); err != nil { + if err := writeFileAtomic(policyPath, policyData); err != nil { return fmt.Errorf("writing policy %q: %w", policyPath, err) } slog.Info("wrote Praxis policy", "path", policyPath, "enforces", pol.Enforced) } else { + // Remove any policy from a previous run. Leaving it would strand a file + // that nothing loads — the generated config carries no `policy` filter + // in this branch — while the entrypoint's `[ -s "$PRAXIS_POLICY" ]` + // check would find it and announce "policy engine will enforce it". + // That is a false claim of enforcement, which is worse than no file. + if err := os.Remove(policyPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("removing stale policy %q: %w", policyPath, err) + } else if err == nil { + slog.Warn("removed a stale Praxis policy from a previous run "+ + "(nothing in this config maps to a policy plugin)", "path", policyPath) + } slog.Info("no Praxis policy written (nothing in the inbound pipeline maps to a policy plugin)", "path", policyPath) } @@ -250,9 +260,7 @@ func writePraxisConfig(cfg *config.Config, outPath, policyPath string) error { if err != nil { return fmt.Errorf("rendering config: %w", err) } - // 0o644: the generated config carries no secrets (it references credential - // and SVID paths, never their contents) and Praxis may run as another user. - if err := os.WriteFile(outPath, data, 0o644); err != nil { + if err := writeFileAtomic(outPath, data); err != nil { return fmt.Errorf("writing %q: %w", outPath, err) } @@ -269,3 +277,53 @@ func writePraxisConfig(cfg *config.Config, outPath, policyPath string) error { } return nil } + +// writeFileAtomic writes data to path via a temporary file in the same +// directory, then renames it into place. +// +// A plain WriteFile truncates first, so an interrupted or partial write leaves a +// truncated document behind — and both of these files are consumed by another +// process. Praxis watches its config for changes and reloads on them, so it can +// observe a half-written file; on the policy side a truncated document is worse +// than a missing one, because it can parse into a policy that enforces less +// than intended. rename(2) within a directory is atomic, so a reader sees either +// the old file or the complete new one. +// +// 0o644: neither file carries secrets — they reference JWKS URLs, issuer and +// audience values, and credential/SVID paths, never the material itself — and +// Praxis may run as a different user than the generator. +func writeFileAtomic(path string, data []byte) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp*") + if err != nil { + return fmt.Errorf("creating temp file in %q: %w", dir, err) + } + tmpName := tmp.Name() + // Best-effort cleanup on every failure path below; a successful rename + // makes this a no-op. + defer func() { _ = os.Remove(tmpName) }() + + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return fmt.Errorf("writing temp file %q: %w", tmpName, err) + } + // fsync before rename: without it the rename can be durable while the + // contents are not, which after a crash yields an empty file at the final + // path — precisely the state the rename was meant to rule out. + if err := tmp.Sync(); err != nil { + tmp.Close() + return fmt.Errorf("syncing temp file %q: %w", tmpName, err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("closing temp file %q: %w", tmpName, err) + } + // CreateTemp makes the file 0o600; widen it before the rename so the final + // file has its intended mode from the instant it becomes visible. + if err := os.Chmod(tmpName, 0o644); err != nil { + return fmt.Errorf("chmod temp file %q: %w", tmpName, err) + } + if err := os.Rename(tmpName, path); err != nil { + return fmt.Errorf("renaming %q to %q: %w", tmpName, path, err) + } + return nil +} From cd252fdc8023ef1f08feb6e2c86c5804d668a616 Mon Sep 17 00:00:00 2001 From: Ed Snible Date: Fri, 21 Aug 2026 13:19:57 -0400 Subject: [PATCH 6/7] feat(praxis): Resolve the inbound audience from a file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lacking a literal audience is no longer a hard error. The previous commit made it one to close a fail-open path, but that overshot: in the common in-cluster shape the audience genuinely exists, it just lives in a file rather than in the YAML. jwt-validation defaults to reading /shared/client-id.txt — the Rossoctl convention, where the operator mounts the workload's Keycloak client ID as a Secret — so refusing to convert those configs rejected the most typical deployment. BuildPolicy and ConvertWithPolicy now take a PolicyOptions carrying an optional AudienceFile, and a policy is produced when EITHER the plugin config names an audience or that file supplies one. Both sources union under jwt-validation's own OR semantics, deduplicated, reusing config.ReadCredentialFile so the trim and empty-file handling match the plugin exactly rather than being reimplemented. Conversion still fails when neither source yields an audience, because that case remains fail-open: Audiences is `omitempty`, so an empty slice omits the key and the engine accepts any token from the trusted issuer. The error now names the flag and path that fix it (--audience-file /shared/client-id.txt, taken from the plugin's own audience_file when set) instead of just refusing. Reading the filesystem is opt-in rather than automatic on the plugin's audience_file: the generator may not run in the pod that will serve traffic, and silently baking in whatever happened to be on the generating machine's disk — under a path the operator never named — would produce a policy whose audience nobody chose. Passing the path is the operator saying "this file is the authority." An explicitly named file that is missing, empty, or whitespace-only is an error, not a fallback. Because the value is resolved once and written into a static document, a warning records that AuthBridge re-reads the file at runtime while the policy does not: rotating the client ID requires regenerating. The container entrypoint passes --audience-file /shared/client-id.txt when that file exists, so the in-cluster shape works with no extra configuration, while standalone runs (no /shared mount) and configs that state their audience inline are unaffected. Verified in-container: a config with only audience_file plus a mounted /shared/client-id.txt now yields `audiences: [agent-team1-weather-service]` and still answers 401 without a token; the same config without the mount fails with the actionable message. Assisted-By: Claude (Anthropic AI) Signed-off-by: Ed Snible --- authbridge/authlib/praxis/policy.go | 158 +++++++++--- authbridge/authlib/praxis/policy_test.go | 234 ++++++++++++++++-- authbridge/authlib/praxis/praxis.go | 6 +- .../cmd/authbridge-praxis/entrypoint.sh | 20 ++ authbridge/cmd/authbridge-praxis/main.go | 12 +- 5 files changed, 359 insertions(+), 71 deletions(-) diff --git a/authbridge/authlib/praxis/policy.go b/authbridge/authlib/praxis/policy.go index 97632b371..8ff321bf9 100644 --- a/authbridge/authlib/praxis/policy.go +++ b/authbridge/authlib/praxis/policy.go @@ -202,14 +202,13 @@ func (c jwtValidationConfig) resolveJWKSURL() string { // audiences returns the accepted audience values, mirroring jwt-validation's // OR semantics: allowed_audiences entries in config order, then the literal -// audience. Deduplicated, first occurrence winning. +// audience, then fileAudience (the resolved contents of an audience file). +// Deduplicated, first occurrence winning. // -// audience_file is NOT resolved here: it names a file read at runtime (the -// Rossoctl client-id convention), and this converter does not read the -// filesystem — a generated policy that silently baked in whatever happened to -// be on the generating machine's disk would be worse than one that reports the -// gap. Callers see it via [PolicyResult.Warnings]. -func (c jwtValidationConfig) audiences() []string { +// fileAudience is passed in rather than read here so that reading the +// filesystem stays an explicit, caller-controlled step — see +// [PolicyOptions.AudienceFile]. Empty means no file audience was resolved. +func (c jwtValidationConfig) audiences(fileAudience string) []string { var ( out []string seen = map[string]bool{} @@ -225,9 +224,57 @@ func (c jwtValidationConfig) audiences() []string { add(a) } add(c.Audience) + add(fileAudience) return out } +// PolicyOptions tunes policy generation. +type PolicyOptions struct { + // AudienceFile is a file to read the expected inbound audience from when + // the plugin config does not state one literally. + // + // jwt-validation's own default is to read the audience from + // /shared/client-id.txt — the Rossoctl convention, where the operator + // mounts the workload's client ID as a Secret. That is a real audience, it + // just is not in the YAML, so refusing to convert such a config would + // reject the most common in-cluster shape. Naming the file here lets the + // audience be resolved into the generated policy. + // + // Reading the filesystem is opt-in and explicit rather than automatic on + // jwt-validation's `audience_file`: the generator may run somewhere other + // than the pod that will serve traffic, and silently baking in whatever + // happened to be on the generating machine's disk — under a path the + // operator never named — would produce a policy whose audience nobody + // chose. Passing the path is the operator saying "this file is the + // authority." + // + // When empty, no file is read. When set but unreadable, missing, or empty, + // [BuildPolicy] returns an error rather than falling through to an + // audience-less policy, since that would be fail-open. + // + // Precedence matches jwt-validation: an explicit `audience` / + // `allowed_audiences` in the plugin config is used as well, unioned with + // the file's value under the same OR semantics. + AudienceFile string +} + +// resolveAudienceFile returns the audience read from opts.AudienceFile, or "" +// when no file was configured. The plugin's own `audience_file` value is used +// only for diagnostics — it names the runtime path, which may not exist here. +func resolveAudienceFile(opts *PolicyOptions) (string, error) { + if opts == nil || opts.AudienceFile == "" { + return "", nil + } + aud, err := config.ReadCredentialFile(opts.AudienceFile) + if err != nil { + return "", fmt.Errorf( + "praxis: reading audience file %q: %w (it was named explicitly, so an unreadable "+ + "or empty file is an error rather than a fallback — a policy without an audience "+ + "accepts any token from the trusted issuer)", opts.AudienceFile, err) + } + return aud, nil +} + // PolicyResult is the outcome of building a policy document. type PolicyResult struct { // Document is the generated policy document. Nil when no AuthBridge @@ -256,20 +303,41 @@ type PolicyResult struct { // skipped, matching AuthBridge dropping it from the pipeline. // // An error is returned only when a jwt-validation plugin is present but cannot -// be translated into something that would actually verify tokens (no issuer, -// or no derivable JWKS URL) — emitting a policy that accepts everything, or -// one the engine rejects at startup, would both be worse than failing here. -func BuildPolicy(cfg *config.Config) (*PolicyResult, error) { +// be translated into something that would actually verify tokens (no issuer, no +// derivable JWKS URL, or no resolvable audience) — emitting a policy that +// accepts everything, or one the engine rejects at startup, would both be worse +// than failing here. +// +// opts may be nil. Set [PolicyOptions.AudienceFile] to convert a config whose +// audience lives in a mounted file rather than in the YAML — the common +// in-cluster shape, since jwt-validation defaults to reading +// /shared/client-id.txt. +func BuildPolicy(cfg *config.Config, opts *PolicyOptions) (*PolicyResult, error) { if cfg == nil { return nil, fmt.Errorf("praxis: nil AuthBridge config") } res := &PolicyResult{} + // Read the audience file once, before the loop: every jwt-validation entry + // in a stage resolves against the same file, and a read error should fail + // the conversion regardless of how many plugins would have used it. + fileAudience, err := resolveAudienceFile(opts) + if err != nil { + return nil, err + } + if fileAudience != "" { + res.Warnings = append(res.Warnings, fmt.Sprintf( + "the inbound audience %q was read from %q at generation time and baked into the "+ + "policy. AuthBridge re-reads that file at runtime and picks up changes on restart; "+ + "the generated policy does not. If the workload's client ID is rotated, regenerate "+ + "the policy.", fileAudience, opts.AudienceFile)) + } + for _, p := range cfg.Pipeline.Inbound.Plugins { if p.Name != "jwt-validation" || p.OnError == "off" { continue } - plugin, warnings, err := jwtPolicyPlugin(p) + plugin, warnings, err := jwtPolicyPlugin(p, fileAudience, opts) if err != nil { return nil, err } @@ -285,8 +353,9 @@ func BuildPolicy(cfg *config.Config) (*PolicyResult, error) { } // jwtPolicyPlugin translates one jwt-validation entry into an identity/jwt -// policy plugin. -func jwtPolicyPlugin(entry config.PluginEntry) (PolicyPlugin, []string, error) { +// policy plugin. fileAudience is the value resolved from +// [PolicyOptions.AudienceFile], or "" when no file was configured. +func jwtPolicyPlugin(entry config.PluginEntry, fileAudience string, opts *PolicyOptions) (PolicyPlugin, []string, error) { var jc jwtValidationConfig if len(entry.Config) > 0 { if err := json.Unmarshal(entry.Config, &jc); err != nil { @@ -366,44 +435,51 @@ func jwtPolicyPlugin(entry config.PluginEntry) (PolicyPlugin, []string, error) { algs, jc.Issuer)) } - // Audience resolution FAILS the conversion when it yields nothing. + // The audience may come from the plugin config or from a file the caller + // named (see [PolicyOptions.AudienceFile]) — either is enough to produce a + // policy. Conversion fails only when NEITHER yields one. // - // This is deliberately an error rather than a warning. Upstream treats an - // empty `audiences` list as "disable aud validation", and TrustedIssuer's - // Audiences field is `omitempty` — so an empty slice does not emit an empty - // list, it omits the key entirely and the engine accepts ANY token from the - // trusted issuer. That is a fail-open weakening of the exact check - // jwt-validation exists to perform, and a warning is the wrong instrument - // for it: warnings are advisory, and the generated policy would still be - // deployed, silently authenticating tokens minted for a different audience. - // - // Both reachable paths here are standard AuthBridge shapes, not edge cases: - // `audience_file` is the Rossoctl client-id convention and the plugin's own - // default, and `audience_mode: per-host` is the waypoint shape. Missing - // issuer and undecidable JWKS are already hard errors above; audience - // belongs with them. - auds := jc.audiences() + // That last case is an error rather than a warning because it is fail-open. + // Upstream treats an empty `audiences` list as "disable aud validation", and + // TrustedIssuer's Audiences field is `omitempty` — so an empty slice does + // not emit an empty list, it omits the key entirely and the engine accepts + // ANY token from the trusted issuer. That silently defeats the exact check + // jwt-validation exists to perform, and a warning is the wrong instrument: + // warnings are advisory, and the policy would still be deployed. Missing + // issuer and undecidable JWKS are already hard errors above; an + // unresolvable audience belongs with them. + auds := jc.audiences(fileAudience) if len(auds) == 0 { + // Name the audience_file the plugin itself points at, since that is + // most likely what the operator should pass as AudienceFile. Fall back + // to the plugin's documented default when the field is empty, because + // applyDefaults fills it in at runtime even when the YAML omits it. + suggest := jc.AudienceFile + if suggest == "" { + suggest = "/shared/client-id.txt" + } switch { case jc.AudienceMode == "per-host": return PolicyPlugin{}, nil, fmt.Errorf( "praxis: jwt-validation uses audience_mode: per-host, which derives the expected " + "audience from each request's Host; the policy engine's trusted_issuers take a " + - "static audience list and cannot express that. Generating a policy anyway " + - "would omit the audiences key entirely and accept ANY token from the issuer. " + - "Set an explicit `audience` (or `allowed_audiences`) on the plugin to convert") + "static audience list and cannot express that. Generating a policy anyway would " + + "omit the audiences key entirely and accept ANY token from the issuer. Set an " + + "explicit `audience` / `allowed_audiences` on the plugin to convert") case jc.AudienceFile != "": return PolicyPlugin{}, nil, fmt.Errorf( - "praxis: jwt-validation reads its expected audience from the file %q at runtime, "+ - "which this converter does not read; generating a policy anyway would omit the "+ - "audiences key entirely and accept ANY token from issuer %q. Set an explicit "+ - "`audience` (or `allowed_audiences`) on the plugin to convert", - jc.AudienceFile, jc.Issuer) + "praxis: jwt-validation reads its expected audience from %q at runtime and the "+ + "plugin config names no literal audience, so none could be resolved; a policy "+ + "without one accepts ANY token from issuer %q. Either point the converter at "+ + "that file (--audience-file %s) so its value is baked into the policy, or set "+ + "an explicit `audience` on the plugin", + jc.AudienceFile, jc.Issuer, suggest) default: return PolicyPlugin{}, nil, fmt.Errorf( - "praxis: jwt-validation declares no audience, so no policy could be generated "+ - "that validates the aud claim; without it any token from issuer %q would be "+ - "accepted. Set `audience` or `allowed_audiences` on the plugin", jc.Issuer) + "praxis: jwt-validation declares no audience, so no policy could be generated that "+ + "validates the aud claim; without it any token from issuer %q would be accepted. "+ + "Set `audience` / `allowed_audiences` on the plugin, or point the converter at "+ + "the file the audience is mounted from (--audience-file %s)", jc.Issuer, suggest) } } diff --git a/authbridge/authlib/praxis/policy_test.go b/authbridge/authlib/praxis/policy_test.go index cbcde304e..d65dc182f 100644 --- a/authbridge/authlib/praxis/policy_test.go +++ b/authbridge/authlib/praxis/policy_test.go @@ -35,7 +35,7 @@ func keycloakJWT(t *testing.T) config.PluginEntry { } func TestBuildPolicy_NilConfig(t *testing.T) { - if _, err := BuildPolicy(nil); err == nil { + if _, err := BuildPolicy(nil, nil); err == nil { t.Fatal("expected an error for a nil config") } } @@ -44,7 +44,7 @@ func TestBuildPolicy_NilConfig(t *testing.T) { // enforce, so no document should be produced — writing one, and pointing a // policy filter at it, would add a filter that enforces nothing. func TestBuildPolicy_NoJWTPlugin_NoDocument(t *testing.T) { - res, err := BuildPolicy(proxySidecar(t, nil)) + res, err := BuildPolicy(proxySidecar(t, nil), nil) if err != nil { t.Fatalf("BuildPolicy: %v", err) } @@ -59,7 +59,7 @@ func TestBuildPolicy_NoJWTPlugin_NoDocument(t *testing.T) { func TestBuildPolicy_JWTValidation(t *testing.T) { res, err := BuildPolicy(proxySidecar(t, func(c *config.Config) { c.Pipeline.Inbound.Plugins = []config.PluginEntry{keycloakJWT(t)} - })) + }), nil) if err != nil { t.Fatalf("BuildPolicy: %v", err) } @@ -117,7 +117,7 @@ func TestBuildPolicy_JWTValidation(t *testing.T) { func TestBuildPolicy_PlaintextJWKS_SetsInsecureAndWarns(t *testing.T) { res, err := BuildPolicy(proxySidecar(t, func(c *config.Config) { c.Pipeline.Inbound.Plugins = []config.PluginEntry{keycloakJWT(t)} - })) + }), nil) if err != nil { t.Fatalf("BuildPolicy: %v", err) } @@ -137,7 +137,7 @@ func TestBuildPolicy_HTTPSJWKS_NoInsecureFlag(t *testing.T) { "jwks_url": "https://idp.example.com/realms/r/protocol/openid-connect/certs", "audience": "svc", })} - })) + }), nil) if err != nil { t.Fatalf("BuildPolicy: %v", err) } @@ -161,7 +161,7 @@ func TestBuildPolicy_ExplicitJWKSWins(t *testing.T) { "keycloak_realm": "r", "audience": "svc", })} - })) + }), nil) if err != nil { t.Fatalf("BuildPolicy: %v", err) } @@ -178,7 +178,7 @@ func TestBuildPolicy_JWKSFromIssuer(t *testing.T) { "issuer": "https://idp.example.com/realms/r", "audience": "svc", })} - })) + }), nil) if err != nil { t.Fatalf("BuildPolicy: %v", err) } @@ -197,7 +197,7 @@ func TestBuildPolicy_AudienceUnion(t *testing.T) { "audience": "primary", "allowed_audiences": []string{"extra-1", "extra-2", "primary"}, })} - })) + }), nil) if err != nil { t.Fatalf("BuildPolicy: %v", err) } @@ -225,7 +225,7 @@ func TestBuildPolicy_AudienceFile_IsError(t *testing.T) { "issuer": "https://idp.example.com/realms/r", "audience_file": "/shared/client-id.txt", })} - })) + }), nil) if err == nil { t.Fatal("expected an error: audience_file cannot be resolved, so aud would go unvalidated") } @@ -242,7 +242,7 @@ func TestBuildPolicy_PerHostAudience_IsError(t *testing.T) { "issuer": "https://idp.example.com/realms/r", "audience_mode": "per-host", })} - })) + }), nil) if err == nil { t.Fatal("expected an error: per-host audience cannot be expressed statically") } @@ -257,19 +257,203 @@ func TestBuildPolicy_NoAudience_IsError(t *testing.T) { c.Pipeline.Inbound.Plugins = []config.PluginEntry{jwtPlugin(t, map[string]any{ "issuer": "https://idp.example.com/realms/r", })} - })) + }), nil) if err == nil { t.Fatal("expected an error when no audience is declared") } } +// writeAudienceFile creates a file holding aud and returns its path. +func writeAudienceFile(t *testing.T, contents string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "client-id.txt") + if err := os.WriteFile(p, []byte(contents), 0o600); err != nil { + t.Fatalf("write audience file: %v", err) + } + return p +} + +// The in-cluster shape: jwt-validation names no literal audience because the +// operator mounts the client ID at /shared/client-id.txt. Pointing the +// converter at that file must produce a policy, not an error. +func TestBuildPolicy_AudienceFromFile(t *testing.T) { + path := writeAudienceFile(t, "agent-team1-weather-service\n") + res, err := BuildPolicy(proxySidecar(t, func(c *config.Config) { + c.Pipeline.Inbound.Plugins = []config.PluginEntry{jwtPlugin(t, map[string]any{ + "issuer": "https://idp.example.com/realms/r", + "audience_file": "/shared/client-id.txt", + })} + }), &PolicyOptions{AudienceFile: path}) + if err != nil { + t.Fatalf("BuildPolicy: %v", err) + } + if res.Document == nil { + t.Fatal("expected a policy document when the audience file supplies the audience") + } + jc := res.Document.Plugins[0].Config.(JWTIdentityConfig) + auds := jc.TrustedIssuers[0].Audiences + // Trailing newline must be trimmed, or the aud claim never matches. + if len(auds) != 1 || auds[0] != "agent-team1-weather-service" { + t.Errorf("audiences = %v, want [agent-team1-weather-service]", auds) + } + // Baking a runtime-read value into a static file is worth stating: rotating + // the client ID silently invalidates the generated policy. + if !containsSubstring(res.Warnings, "regenerate") { + t.Errorf("expected a warning that the value is baked in, got %v", res.Warnings) + } +} + +// A file audience unions with an explicit one under the same OR semantics +// jwt-validation uses, rather than either replacing the other. +func TestBuildPolicy_AudienceFileUnionsWithLiteral(t *testing.T) { + path := writeAudienceFile(t, "from-file") + res, err := BuildPolicy(proxySidecar(t, func(c *config.Config) { + c.Pipeline.Inbound.Plugins = []config.PluginEntry{jwtPlugin(t, map[string]any{ + "issuer": "https://idp.example.com/realms/r", + "audience": "from-config", + })} + }), &PolicyOptions{AudienceFile: path}) + if err != nil { + t.Fatalf("BuildPolicy: %v", err) + } + jc := res.Document.Plugins[0].Config.(JWTIdentityConfig) + auds := jc.TrustedIssuers[0].Audiences + if len(auds) != 2 { + t.Fatalf("audiences = %v, want both the config and file values", auds) + } + found := map[string]bool{} + for _, a := range auds { + found[a] = true + } + if !found["from-config"] || !found["from-file"] { + t.Errorf("audiences = %v, want both from-config and from-file", auds) + } +} + +// A duplicate between file and config must dedupe, not emit the value twice. +func TestBuildPolicy_AudienceFileDedupes(t *testing.T) { + path := writeAudienceFile(t, "same") + res, err := BuildPolicy(proxySidecar(t, func(c *config.Config) { + c.Pipeline.Inbound.Plugins = []config.PluginEntry{jwtPlugin(t, map[string]any{ + "issuer": "https://idp.example.com/realms/r", + "audience": "same", + })} + }), &PolicyOptions{AudienceFile: path}) + if err != nil { + t.Fatalf("BuildPolicy: %v", err) + } + jc := res.Document.Plugins[0].Config.(JWTIdentityConfig) + if auds := jc.TrustedIssuers[0].Audiences; len(auds) != 1 { + t.Errorf("audiences = %v, want one deduplicated entry", auds) + } +} + +// An explicitly named audience file that cannot be read is an error, not a +// silent fallback: falling through would emit a policy with no audience, which +// accepts any token from the issuer. +func TestBuildPolicy_AudienceFileUnreadable_IsError(t *testing.T) { + for _, tc := range []struct { + name string + contents *string + }{ + {name: "missing file"}, + {name: "empty file", contents: strPtr("")}, + {name: "whitespace only", contents: strPtr(" \n")}, + } { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "absent.txt") + if tc.contents != nil { + path = writeAudienceFile(t, *tc.contents) + } + _, err := BuildPolicy(proxySidecar(t, func(c *config.Config) { + c.Pipeline.Inbound.Plugins = []config.PluginEntry{jwtPlugin(t, map[string]any{ + "issuer": "https://idp.example.com/realms/r", + "audience_file": "/shared/client-id.txt", + })} + }), &PolicyOptions{AudienceFile: path}) + if err == nil { + t.Error("expected an error: an unreadable audience file must not fall through " + + "to an audience-less policy") + } + }) + } +} + +// A whitespace-only file trims to empty and must never become an audience +// entry. ReadCredentialFile rejects a zero-byte file outright, but a file of +// only whitespace is non-zero on disk and trims away — so this guards the trim +// path rather than the size check. Uses a plugin with a literal audience so the +// conversion still succeeds: the assertion is that the blank value is not +// silently added alongside it. +func TestBuildPolicy_AudienceFileWhitespaceNotAnAudience(t *testing.T) { + path := writeAudienceFile(t, "\n\t \n") + res, err := BuildPolicy(proxySidecar(t, func(c *config.Config) { + c.Pipeline.Inbound.Plugins = []config.PluginEntry{jwtPlugin(t, map[string]any{ + "issuer": "https://idp.example.com/realms/r", + "audience": "real-aud", + })} + }), &PolicyOptions{AudienceFile: path}) + if err != nil { + t.Fatalf("BuildPolicy: %v", err) + } + auds := res.Document.Plugins[0].Config.(JWTIdentityConfig).TrustedIssuers[0].Audiences + if len(auds) != 1 || auds[0] != "real-aud" { + t.Errorf("audiences = %v, want only [real-aud]: a whitespace-only file must contribute "+ + "nothing, and an empty entry would disable aud matching for that value", auds) + } + for _, a := range auds { + if strings.TrimSpace(a) == "" { + t.Errorf("audiences contains a blank entry: %q", a) + } + } +} + +// No audience file configured keeps the previous behavior: the literal audience +// alone is enough. +func TestBuildPolicy_NoAudienceFile_LiteralStillWorks(t *testing.T) { + res, err := BuildPolicy(proxySidecar(t, func(c *config.Config) { + c.Pipeline.Inbound.Plugins = []config.PluginEntry{keycloakJWT(t)} + }), &PolicyOptions{}) + if err != nil { + t.Fatalf("BuildPolicy: %v", err) + } + if res.Document == nil { + t.Fatal("expected a policy document") + } + if containsSubstring(res.Warnings, "regenerate") { + t.Errorf("no audience file was read, so no baked-in warning belongs: %v", res.Warnings) + } +} + +// The error for an unresolvable audience should point at the flag that fixes +// it, naming the plugin's own audience_file path. +func TestBuildPolicy_AudienceError_SuggestsAudienceFile(t *testing.T) { + _, err := BuildPolicy(proxySidecar(t, func(c *config.Config) { + c.Pipeline.Inbound.Plugins = []config.PluginEntry{jwtPlugin(t, map[string]any{ + "issuer": "https://idp.example.com/realms/r", + "audience_file": "/shared/client-id.txt", + })} + }), nil) + if err == nil { + t.Fatal("expected an error when no audience can be resolved") + } + if !strings.Contains(err.Error(), "--audience-file") { + t.Errorf("error should point at the flag that fixes it: %v", err) + } + if !strings.Contains(err.Error(), "/shared/client-id.txt") { + t.Errorf("error should name the plugin's audience_file: %v", err) + } +} + +func strPtr(s string) *string { return &s } + // Guard the fail-open mechanism directly: if Audiences is ever populated empty, // omitempty drops the key and the engine stops validating aud. Any policy this // converter emits must carry at least one audience. func TestBuildPolicy_EmittedPolicyAlwaysHasAudiences(t *testing.T) { res, err := BuildPolicy(proxySidecar(t, func(c *config.Config) { c.Pipeline.Inbound.Plugins = []config.PluginEntry{keycloakJWT(t)} - })) + }), nil) if err != nil { t.Fatalf("BuildPolicy: %v", err) } @@ -304,7 +488,7 @@ func TestBuildPolicy_EmittedPolicyAlwaysHasAudiences(t *testing.T) { func TestBuildPolicy_DefaultAlgorithms(t *testing.T) { res, err := BuildPolicy(proxySidecar(t, func(c *config.Config) { c.Pipeline.Inbound.Plugins = []config.PluginEntry{keycloakJWT(t)} - })) + }), nil) if err != nil { t.Fatalf("BuildPolicy: %v", err) } @@ -342,7 +526,7 @@ func TestBuildPolicy_ExplicitAlgorithmsWin(t *testing.T) { "audience": "svc", "algorithms": []string{"ES384"}, })} - })) + }), nil) if err != nil { t.Fatalf("BuildPolicy: %v", err) } @@ -373,7 +557,7 @@ func TestBuildPolicy_MalformedJWKS_IsError(t *testing.T) { "keycloak_url": tc.keycloakURL, "keycloak_realm": "r", })} - })) + }), nil) if err == nil { t.Errorf("expected an error for keycloak_url %q", tc.keycloakURL) } @@ -390,7 +574,7 @@ func TestBuildPolicy_BypassPaths_Warns(t *testing.T) { "audience": "svc", "bypass_paths": []string{"/healthz", "/metrics"}, })} - })) + }), nil) if err != nil { t.Fatalf("BuildPolicy: %v", err) } @@ -406,7 +590,7 @@ func TestBuildPolicy_MissingIssuer_IsError(t *testing.T) { c.Pipeline.Inbound.Plugins = []config.PluginEntry{jwtPlugin(t, map[string]any{ "audience": "svc", })} - })) + }), nil) if err == nil { t.Fatal("expected an error when jwt-validation has no issuer") } @@ -422,7 +606,7 @@ func TestBuildPolicy_DisabledPlugin_NoDocument(t *testing.T) { e := keycloakJWT(t) e.OnError = "off" c.Pipeline.Inbound.Plugins = []config.PluginEntry{e} - })) + }), nil) if err != nil { t.Fatalf("BuildPolicy: %v", err) } @@ -438,7 +622,7 @@ func TestConvertWithPolicy_EmitsPolicyFilter(t *testing.T) { cfg := proxySidecar(t, func(c *config.Config) { c.Pipeline.Inbound.Plugins = []config.PluginEntry{keycloakJWT(t)} }) - res, pol, err := ConvertWithPolicy(cfg, "/tmp/praxis-policy.yaml") + res, pol, err := ConvertWithPolicy(cfg, "/tmp/praxis-policy.yaml", nil) if err != nil { t.Fatalf("ConvertWithPolicy: %v", err) } @@ -500,7 +684,7 @@ func TestConvertWithoutPolicy_NoPolicyFilter(t *testing.T) { // A pipeline with no enforceable plugin must not get a policy filter, since it // would point at a file the caller never writes and Praxis would fail to start. func TestConvertWithPolicy_NoEnforceable_NoFilter(t *testing.T) { - res, pol, err := ConvertWithPolicy(proxySidecar(t, nil), "/tmp/praxis-policy.yaml") + res, pol, err := ConvertWithPolicy(proxySidecar(t, nil), "/tmp/praxis-policy.yaml", nil) if err != nil { t.Fatalf("ConvertWithPolicy: %v", err) } @@ -521,7 +705,7 @@ func TestConvertWithPolicy_NoEnforceable_NoFilter(t *testing.T) { func TestConvertWithPolicy_SinglePolicyFilterPerChain(t *testing.T) { res, _, err := ConvertWithPolicy(proxySidecar(t, func(c *config.Config) { c.Pipeline.Inbound.Plugins = []config.PluginEntry{keycloakJWT(t), keycloakJWT(t)} - }), "/tmp/praxis-policy.yaml") + }), "/tmp/praxis-policy.yaml", nil) if err != nil { t.Fatalf("ConvertWithPolicy: %v", err) } @@ -546,7 +730,7 @@ func TestConvertWithPolicy_OutboundJWTDoesNotEmitPolicyFilter(t *testing.T) { // Same plugin name, wrong direction. c.Pipeline.Outbound.Plugins = []config.PluginEntry{keycloakJWT(t)} }) - res, pol, err := ConvertWithPolicy(cfg, "/tmp/praxis-policy.yaml") + res, pol, err := ConvertWithPolicy(cfg, "/tmp/praxis-policy.yaml", nil) if err != nil { t.Fatalf("ConvertWithPolicy: %v", err) } @@ -591,7 +775,7 @@ func TestConvertWithPolicy_NoBackend_WarnsPolicyNotEnforced(t *testing.T) { cfg.Pipeline.Inbound.Plugins = []config.PluginEntry{keycloakJWT(t)} config.ApplyPreset(cfg) - res, pol, err := ConvertWithPolicy(cfg, "/tmp/praxis-policy.yaml") + res, pol, err := ConvertWithPolicy(cfg, "/tmp/praxis-policy.yaml", nil) if err != nil { t.Fatalf("ConvertWithPolicy: %v", err) } @@ -614,7 +798,7 @@ func TestConvertWithPolicy_NoBackend_WarnsPolicyNotEnforced(t *testing.T) { func TestRenderPolicyResult_ParsesAsYAML(t *testing.T) { pol, err := BuildPolicy(proxySidecar(t, func(c *config.Config) { c.Pipeline.Inbound.Plugins = []config.PluginEntry{keycloakJWT(t)} - })) + }), nil) if err != nil { t.Fatalf("BuildPolicy: %v", err) } @@ -738,7 +922,7 @@ func TestGeneratedPolicy_ValidatesWithPraxis(t *testing.T) { policyPath := filepath.Join(dir, "praxis-policy.yaml") cfgPath := filepath.Join(dir, "praxis-config.yaml") - res, pol, err := ConvertWithPolicy(cfg, policyPath) + res, pol, err := ConvertWithPolicy(cfg, policyPath, nil) if err != nil { t.Fatalf("ConvertWithPolicy: %v", err) } diff --git a/authbridge/authlib/praxis/praxis.go b/authbridge/authlib/praxis/praxis.go index afdf7025b..1c761e868 100644 --- a/authbridge/authlib/praxis/praxis.go +++ b/authbridge/authlib/praxis/praxis.go @@ -956,8 +956,10 @@ func policyFilter(path string, enforces []string) Filter { // // The returned Result carries the policy's fidelity warnings alongside its own, // so a single caller-side loop surfaces every caveat about the generated pair. -func ConvertWithPolicy(cfg *config.Config, policyPath string) (*Result, *PolicyResult, error) { - pol, err := BuildPolicy(cfg) +// polOpts may be nil; see [PolicyOptions] for what it tunes (notably resolving +// the inbound audience from a mounted file). +func ConvertWithPolicy(cfg *config.Config, policyPath string, polOpts *PolicyOptions) (*Result, *PolicyResult, error) { + pol, err := BuildPolicy(cfg, polOpts) if err != nil { return nil, nil, err } diff --git a/authbridge/cmd/authbridge-praxis/entrypoint.sh b/authbridge/cmd/authbridge-praxis/entrypoint.sh index 361bccbff..9cdabe5ff 100644 --- a/authbridge/cmd/authbridge-praxis/entrypoint.sh +++ b/authbridge/cmd/authbridge-praxis/entrypoint.sh @@ -38,12 +38,32 @@ PRAXIS_POLICY="${PRAXIS_POLICY:-/tmp/praxis-policy.yaml}" # AuthBridge images mount their runtime config at. AUTHBRIDGE_CONFIG="${AUTHBRIDGE_CONFIG:-/etc/authbridge/config.yaml}" +# Where the inbound audience is read from when the jwt-validation plugin config +# names none literally. /shared/client-id.txt is the Rossoctl convention — the +# operator mounts the workload's Keycloak client ID there, and jwt-validation +# defaults to reading it — so this is the in-cluster shape. +# +# Passed only when the file actually exists. The generator treats an explicitly +# named but unreadable audience file as an error (a policy with no audience +# accepts any token from the issuer), which is right for a deliberate flag but +# wrong as an unconditional default: a config that states its audience inline +# needs no file, and standalone runs have no /shared mount at all. +AUDIENCE_FILE="${AUDIENCE_FILE:-/shared/client-id.txt}" + +AUDIENCE_ARGS="" +if [ -s "${AUDIENCE_FILE}" ]; then + echo "[entrypoint] Using ${AUDIENCE_FILE} as the inbound audience source" + AUDIENCE_ARGS="--audience-file ${AUDIENCE_FILE}" +fi + # --- Phase 1: generate the Praxis config from the AuthBridge config --- echo "[entrypoint] Generating Praxis config from ${AUTHBRIDGE_CONFIG}..." +# shellcheck disable=SC2086 # AUDIENCE_ARGS is intentionally word-split (empty = omitted) /usr/local/bin/authbridge-praxis \ --config "${AUTHBRIDGE_CONFIG}" \ --praxis-config-out "${PRAXIS_CONFIG}" \ --praxis-policy-out "${PRAXIS_POLICY}" \ + ${AUDIENCE_ARGS} \ "$@" # Fail loudly rather than handing Praxis a path that does not exist: the diff --git a/authbridge/cmd/authbridge-praxis/main.go b/authbridge/cmd/authbridge-praxis/main.go index 901a9192e..cb3ea2a91 100644 --- a/authbridge/cmd/authbridge-praxis/main.go +++ b/authbridge/cmd/authbridge-praxis/main.go @@ -99,6 +99,11 @@ func main() { praxisPolicyOut := flag.String("praxis-policy-out", defaultPraxisPolicyPath, "path to write the generated Praxis policy document to (referenced by the "+ "generated config's policy filter; requires Praxis built with --features policy-engine)") + audienceFile := flag.String("audience-file", "", + "read the expected inbound JWT audience from this file when the jwt-validation "+ + "plugin config names none literally (the in-cluster default is the operator-mounted "+ + "/shared/client-id.txt). The value is baked into the generated policy, so regenerate "+ + "it if the workload's client ID is rotated") showVersion := flag.Bool("version", false, "print version and exit") flag.Parse() @@ -186,7 +191,7 @@ func main() { // dropping them silently, and they are logged at WARN below — a generated // proxy that no longer enforces inbound auth is precisely the kind of thing // that must not be discoverable only by reading the output file. - if err := writePraxisConfig(bootCfg, *praxisOut, *praxisPolicyOut); err != nil { + if err := writePraxisConfig(bootCfg, *praxisOut, *praxisPolicyOut, *audienceFile); err != nil { log.Fatalf("failed to write Praxis config: %v", err) } @@ -219,8 +224,9 @@ func main() { // Note the generated `policy` filter requires Praxis to be built with the // policy-engine cargo feature; a default-feature build rejects it as an unknown // filter type. -func writePraxisConfig(cfg *config.Config, outPath, policyPath string) error { - res, pol, err := praxis.ConvertWithPolicy(cfg, policyPath) +func writePraxisConfig(cfg *config.Config, outPath, policyPath, audienceFile string) error { + res, pol, err := praxis.ConvertWithPolicy(cfg, policyPath, + &praxis.PolicyOptions{AudienceFile: audienceFile}) if err != nil { return fmt.Errorf("converting config: %w", err) } From 7840479155208f71bd9b1a5b206356636ab3380e Mon Sep 17 00:00:00 2001 From: Ed Snible Date: Fri, 21 Aug 2026 16:46:50 -0400 Subject: [PATCH 7/7] fix(praxis): Warn on observe-mode flip; pin praxis by SHA and --locked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four review findings. on_error: observe now warns (policy.go). Observe is shadow mode: AuthBridge evaluates jwt-validation and may still return Reject, but the framework converts that Reject into a pass-through and counts it as Shadow=true. So an operator canarying jwt-validation in observe is DELIBERATELY admitting unauthenticated traffic while they watch the shadow-deny counter. The entry previously fell through and got on_error: fail, silently inverting that: the generated proxy 401s all of it. Checked for a counterpart before settling on a warning — the policy engine has no dry-run or shadow mode, and its own OnError (fail / ignore / disable) covers plugin *errors*, not deny decisions, so `ignore` cannot emulate it either. Kept as a warning rather than an error because the flip is toward MORE enforcement: the generated proxy is stricter, so nothing AuthBridge would have blocked gets through. It can still cause an outage mid-canary, so the warning names the flip and the 401s explicitly. While there, replaced the `p.OnError == "off"` string literals in both policy.go and praxis.go with pipeline.ErrorPolicyOff via Resolved(), so "" (meaning enforce) is handled by the type rather than by each caller. The cargo build now passes --locked. Pinning the source commit without pinning the lockfile left most of the dependency surface unpinned: cargo was free to resolve newer semver-compatible versions than upstream tested and released, so two builds of the same PRAXIS_REF could ship different dependency trees, and a broken or compromised point release of any transitive crate would be pulled in silently. PRAXIS_REF is now the immutable commit SHA (7c6cef76, release v0.5.3) rather than the tag, matching what the file's own header comment already claimed. A tag is better than a branch but still mutable — it can be force-moved or deleted upstream, which is the same hazard one step removed. The version stays in a comment for readability, on its own line rather than trailing the ARG (an inline comment there is stripped by podman but treated as part of the value by other builders). The fetch's tag form is kept first so a tag-valued override still works; the build log confirms the SHA form takes over and checks out "release: v0.5.3". policyDefaultLeewaySec now explains itself. Upstream treats leeway_seconds: 0 as "use the resolver default", and that default is currently 60s, so 60 is upstream's effective value stated explicitly rather than a departure from it — emitting it keeps the document self-describing and pins the behavior if that internal default changes. The comment also records that this is marginally more permissive than AuthBridge, which sets no skew tolerance and so takes jwx's strict default. Verified by rebuilding the image: the SHA fetch resolves and checks out 7c6cef7, --locked builds clean, the policy-engine gate still passes, and the shipped binary reports "praxis 0.5.3 (7c6cef7)". Assisted-By: Claude (Anthropic AI) Signed-off-by: Ed Snible --- authbridge/authlib/praxis/policy.go | 65 +++++++++++++++++++-- authbridge/authlib/praxis/policy_test.go | 53 ++++++++++++++++- authbridge/authlib/praxis/praxis.go | 5 +- authbridge/cmd/authbridge-praxis/Dockerfile | 57 ++++++++++++------ 4 files changed, 153 insertions(+), 27 deletions(-) diff --git a/authbridge/authlib/praxis/policy.go b/authbridge/authlib/praxis/policy.go index 8ff321bf9..566e8bd54 100644 --- a/authbridge/authlib/praxis/policy.go +++ b/authbridge/authlib/praxis/policy.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/rossoctl/cortex/authbridge/authlib/config" + "github.com/rossoctl/cortex/authbridge/authlib/pipeline" ) // This file generates the Praxis *policy document* — the second file the @@ -29,11 +30,30 @@ import ( // engine's public API: `identity/jwt` selects the JWT identity resolver, and // `identity.resolve` is the hook it runs on. const ( - policyPluginKindJWT = "identity/jwt" - policyHookIdentity = "identity.resolve" - policyModeSequential = "sequential" - policyOnErrorFail = "fail" - policyClaimMapperStd = "standard" + policyPluginKindJWT = "identity/jwt" + policyHookIdentity = "identity.resolve" + policyModeSequential = "sequential" + policyOnErrorFail = "fail" + policyClaimMapperStd = "standard" + + // policyDefaultLeewaySec is the clock-skew tolerance, in seconds, written + // into each trusted issuer. + // + // Upstream treats leeway_seconds: 0 as "use the resolver default", and that + // default is currently 60s (see the identity-jwt resolver: `if + // issuer.leeway_seconds == 0 { 60 } else { ... }`). So 60 is not a departure + // from upstream — it is upstream's effective value, stated explicitly rather + // than left implicit. Emitting it makes the generated policy self-describing + // and pins the behavior if that internal default ever changes; writing 0 + // would silently inherit whatever a future version picks. + // + // It IS slightly more permissive than AuthBridge, which sets no skew + // tolerance and so takes jwx's strict default: a token up to 60s past `exp` + // is accepted by the generated policy but rejected by AuthBridge. That is + // the standard allowance for clock drift between the proxy and the IdP, and + // tightening it to 0 would make the generated proxy reject tokens for + // ordinary NTP jitter. Set trusted_issuers[].leeway_seconds in the generated + // policy to override. policyDefaultLeewaySec = 60 // policyJWTPluginPriority orders the identity plugin within its mode @@ -334,9 +354,42 @@ func BuildPolicy(cfg *config.Config, opts *PolicyOptions) (*PolicyResult, error) } for _, p := range cfg.Pipeline.Inbound.Plugins { - if p.Name != "jwt-validation" || p.OnError == "off" { + if p.Name != "jwt-validation" { continue } + // off is a kill-switch: AuthBridge does not dispatch the plugin at all, + // so there is nothing to translate and no gap to report. + if p.OnError.Resolved() == pipeline.ErrorPolicyOff { + continue + } + // observe is shadow mode: the plugin still evaluates and may still + // return Reject, but the framework converts that Reject into a + // pass-through and records it as Shadow=true. So an operator canarying + // jwt-validation in observe is DELIBERATELY letting unauthenticated + // traffic reach the app while they watch the shadow-deny counter. + // + // The policy engine has no equivalent — its OnError covers plugin + // errors, not deny decisions, and there is no dry-run mode — so the + // generated plugin necessarily gets on_error: fail and enforces for + // real. That inverts the operator's intent: traffic they were + // intentionally admitting starts getting 401s. + // + // This is a warning rather than an error because the flip is toward + // MORE enforcement, not less: the generated proxy is stricter than the + // AuthBridge config, so no request that AuthBridge would have blocked + // gets through. Failing the conversion would block a legitimate, + // documented rollout shape over a difference that cannot cause a + // bypass. But it can cause an outage, so it must be stated loudly. + if p.OnError.Resolved() == pipeline.ErrorPolicyObserve { + res.Warnings = append(res.Warnings, fmt.Sprintf( + "jwt-validation is configured with on_error: %s (shadow mode), which in AuthBridge "+ + "evaluates the plugin but converts a rejection into a pass-through — "+ + "unauthenticated requests reach the application and are only counted. The policy "+ + "engine has no shadow equivalent, so the generated policy ENFORCES: requests that "+ + "AuthBridge currently admits will get 401. If this is a canary rollout, expect "+ + "the generated proxy to be stricter than what you are canarying.", + pipeline.ErrorPolicyObserve)) + } plugin, warnings, err := jwtPolicyPlugin(p, fileAudience, opts) if err != nil { return nil, err diff --git a/authbridge/authlib/praxis/policy_test.go b/authbridge/authlib/praxis/policy_test.go index d65dc182f..7029caf46 100644 --- a/authbridge/authlib/praxis/policy_test.go +++ b/authbridge/authlib/praxis/policy_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/rossoctl/cortex/authbridge/authlib/config" + "github.com/rossoctl/cortex/authbridge/authlib/pipeline" "gopkg.in/yaml.v3" ) @@ -599,12 +600,62 @@ func TestBuildPolicy_MissingIssuer_IsError(t *testing.T) { } } +// on_error: observe is shadow mode — AuthBridge evaluates jwt-validation but +// converts its rejection into a pass-through, so unauthenticated requests reach +// the app on purpose. The policy engine has no shadow equivalent, so the +// generated policy enforces for real and 401s that traffic. The flip is toward +// MORE enforcement (so not a bypass, hence a warning not an error), but it can +// cause an outage for an operator mid-canary and must be stated. +func TestBuildPolicy_ObservePlugin_WarnsAboutEnforcementFlip(t *testing.T) { + e := keycloakJWT(t) + e.OnError = pipeline.ErrorPolicyObserve + res, err := BuildPolicy(proxySidecar(t, func(c *config.Config) { + c.Pipeline.Inbound.Plugins = []config.PluginEntry{e} + }), nil) + if err != nil { + t.Fatalf("BuildPolicy: %v", err) + } + // Still translated: observe means "evaluate but don't block", so the plugin + // is active and belongs in the policy — just with different consequences. + if res.Document == nil { + t.Fatal("expected a policy document: observe still evaluates the plugin") + } + if !containsSubstring(res.Warnings, "shadow mode") { + t.Errorf("expected a warning naming shadow mode, got %v", res.Warnings) + } + if !containsSubstring(res.Warnings, "401") { + t.Errorf("the warning should say what changes for live traffic, got %v", res.Warnings) + } + // The emitted plugin is fail-closed regardless, which is the whole point of + // the warning. + if res.Document.Plugins[0].OnError != policyOnErrorFail { + t.Errorf("on_error = %q, want %q", res.Document.Plugins[0].OnError, policyOnErrorFail) + } +} + +// enforce (and the empty default) is the ordinary case and must not warn. +func TestBuildPolicy_EnforcePlugin_NoObserveWarning(t *testing.T) { + for _, policy := range []pipeline.ErrorPolicy{"", pipeline.ErrorPolicyEnforce} { + e := keycloakJWT(t) + e.OnError = policy + res, err := BuildPolicy(proxySidecar(t, func(c *config.Config) { + c.Pipeline.Inbound.Plugins = []config.PluginEntry{e} + }), nil) + if err != nil { + t.Fatalf("BuildPolicy(on_error=%q): %v", policy, err) + } + if containsSubstring(res.Warnings, "shadow mode") { + t.Errorf("on_error=%q must not warn about shadow mode: %v", policy, res.Warnings) + } + } +} + // on_error: off means AuthBridge drops the plugin, so no policy is generated // for it — otherwise the Praxis proxy would enforce what AuthBridge did not. func TestBuildPolicy_DisabledPlugin_NoDocument(t *testing.T) { res, err := BuildPolicy(proxySidecar(t, func(c *config.Config) { e := keycloakJWT(t) - e.OnError = "off" + e.OnError = pipeline.ErrorPolicyOff c.Pipeline.Inbound.Plugins = []config.PluginEntry{e} }), nil) if err != nil { diff --git a/authbridge/authlib/praxis/praxis.go b/authbridge/authlib/praxis/praxis.go index 1c761e868..bc3af7eec 100644 --- a/authbridge/authlib/praxis/praxis.go +++ b/authbridge/authlib/praxis/praxis.go @@ -66,6 +66,7 @@ import ( "strings" "github.com/rossoctl/cortex/authbridge/authlib/config" + "github.com/rossoctl/cortex/authbridge/authlib/pipeline" "gopkg.in/yaml.v3" ) @@ -867,7 +868,9 @@ func pluginFilters(plugins []config.PluginEntry, dir direction, opts *Options) ( ) for _, p := range plugins { - if p.OnError == "off" { + // off is a kill-switch: AuthBridge does not dispatch the plugin, so it is + // not a translation gap. + if p.OnError.Resolved() == pipeline.ErrorPolicyOff { continue } name := p.Name diff --git a/authbridge/cmd/authbridge-praxis/Dockerfile b/authbridge/cmd/authbridge-praxis/Dockerfile index d5636eaa4..58caf9b0a 100644 --- a/authbridge/cmd/authbridge-praxis/Dockerfile +++ b/authbridge/cmd/authbridge-praxis/Dockerfile @@ -21,8 +21,9 @@ # # Praxis source is not vendored in this repo, so the Rust stage fetches it from # git. PRAXIS_REPO / PRAXIS_REF pin what gets built; PRAXIS_REF defaults to a -# commit rather than a branch so an image rebuild is reproducible. Point -# PRAXIS_REPO at a fork to build that instead. +# commit SHA rather than a tag or branch, so an image rebuild is reproducible +# even if the tag is moved or deleted upstream. Point PRAXIS_REPO at a fork to +# build that instead. # ── Stage 1: Build the Praxis proxy binary (with the policy engine) ───────── # @@ -49,28 +50,38 @@ ENV OPENSSL_STATIC=1 RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static pkgconf cmake make g++ git ARG PRAXIS_REPO=https://github.com/praxis-proxy/praxis.git -# A release tag, not a branch: pinning to `main` would make image rebuilds +# An immutable commit SHA, not a tag or branch. A branch would make rebuilds # non-reproducible and could silently pick up a schema change the generator in -# stage 2 does not emit for. v0.5.3 carries the policy-engine feature and the -# `policy` filter fields this image's generated configs use. -ARG PRAXIS_REF=v0.5.3 +# stage 2 does not emit for; a tag is better but still mutable — it can be +# force-moved or deleted upstream, and a build that silently follows it is the +# same hazard one step removed. The SHA below is release v0.5.3, which carries +# the policy-engine feature and the `policy` filter fields this image's +# generated configs use. +# +# To bump: resolve the new tag to its commit and update both the SHA and the +# comment together, so the human-readable version never drifts from what builds. +# git ls-remote --tags https://github.com/praxis-proxy/praxis.git v0.5.4 +# +# PRAXIS_REF = v0.5.3 +ARG PRAXIS_REF=7c6cef76a9b8449c3617dacfa597ee6d7ac9d82e WORKDIR /praxis # Fetch exactly PRAXIS_REF, or fail. # -# Two shapes are accepted — a tag (the default) or a bare SHA / branch — and each -# is checked out from the ref it just fetched. That coupling is the point: an -# earlier version fell back to fetching ALL branch tips and checking out -# FETCH_HEAD, so a transient failure fetching the pinned tag would silently build -# main instead — defeating the reproducibility the PRAXIS_REF comment above -# promises, on exactly the condition it was meant to guarantee. Failing the build -# is the only safe outcome; a stage that quietly compiles different source than -# it was told to is worse than one that stops. -# -# Both shapes are attempted because fetching an arbitrary commit by SHA requires -# the server to permit it, which GitHub does not always do: a SHA-pinned build -# needs the second form, a tag-pinned build the first. +# Two shapes are accepted — a tag or a bare SHA / branch — and each is checked +# out from the ref it just fetched. That coupling is the point: an earlier +# version fell back to fetching ALL branch tips and checking out FETCH_HEAD, so +# a transient failure fetching the pinned ref would silently build main instead +# — defeating the reproducibility the PRAXIS_REF comment above promises, on +# exactly the condition it was meant to guarantee. Failing the build is the only +# safe outcome; a stage that quietly compiles different source than it was told +# to is worse than one that stops. +# +# The default PRAXIS_REF is a SHA, which takes the second form: fetching an +# arbitrary commit by SHA needs the server to permit it (GitHub allows this for +# public repos, but not universally), and the tag form is kept first so a +# tag-valued override still works. RUN git init -q . \ && git remote add origin "${PRAXIS_REPO}" \ && ( ( git fetch -q --depth 1 origin "refs/tags/${PRAXIS_REF}:refs/tags/${PRAXIS_REF}" \ @@ -83,7 +94,15 @@ RUN git init -q . \ # `policy-engine` pulls in the praxis-policy crates (identity/jwt, Cedar PDP, # OAuth delegator). Build only the server crate; the workspace's test and # benchmark members are not needed for the binary. -RUN cargo build --release -p praxis-proxy --features policy-engine \ +# +# --locked builds against upstream's committed Cargo.lock and fails if that file +# would need to change. Without it cargo is free to resolve newer semver- +# compatible dependency versions than the ones upstream tested and released, +# so two builds of the same PRAXIS_REF could ship different dependency trees — +# and a compromised or simply broken point release of any transitive crate would +# be pulled in silently. Pinning the source commit without pinning the lockfile +# leaves most of the dependency surface unpinned. +RUN cargo build --release --locked -p praxis-proxy --features policy-engine \ && cp target/release/praxis /praxis-bin \ && strip /praxis-bin