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/ diff --git a/authbridge/authlib/praxis/policy.go b/authbridge/authlib/praxis/policy.go new file mode 100644 index 000000000..566e8bd54 --- /dev/null +++ b/authbridge/authlib/praxis/policy.go @@ -0,0 +1,639 @@ +package praxis + +import ( + "bytes" + "encoding/json" + "fmt" + "net/url" + "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 +// `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 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 + // 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"` + // 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 +// 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, then fileAudience (the resolved contents of an audience file). +// Deduplicated, first occurrence winning. +// +// 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{} + ) + 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) + 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 + // 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, 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" { + 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 + } + 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. 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 { + 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 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 := 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 "+ + "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)) + } + + // 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)) + } + + // 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. + // + // 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` / `allowed_audiences` on the plugin to convert") + case jc.AudienceFile != "": + return PolicyPlugin{}, nil, fmt.Errorf( + "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` / `allowed_audiences` on the plugin, or point the converter at "+ + "the file the audience is mounted from (--audience-file %s)", jc.Issuer, suggest) + } + } + + 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: algs, + 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..7029caf46 --- /dev/null +++ b/authbridge/authlib/praxis/policy_test.go @@ -0,0 +1,1033 @@ +package praxis + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/rossoctl/cortex/authbridge/authlib/config" + "github.com/rossoctl/cortex/authbridge/authlib/pipeline" + "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, 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), 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)} + }), nil) + 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)} + }), nil) + 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", + })} + }), nil) + 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", + })} + }), nil) + 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", + })} + }), nil) + 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"}, + })} + }), nil) + 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. 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", + })} + }), nil) + 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", + })} + }), nil) + 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", + })} + }), 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) + } + 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)} + }), nil) + if err != nil { + t.Fatalf("BuildPolicy: %v", err) + } + jc := res.Document.Plugins[0].Config.(JWTIdentityConfig) + 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) + } + } + // 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, "algorithms") { + t.Errorf("defaulting the algorithm list must be reported, got %v", res.Warnings) + } +} + +// 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": "svc", + "algorithms": []string{"ES384"}, + })} + }), nil) + if err != nil { + t.Fatalf("BuildPolicy: %v", err) + } + 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", + })} + }), nil) + if err == nil { + t.Errorf("expected an error for keycloak_url %q", tc.keycloakURL) + } + }) + } +} + +// 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"}, + })} + }), nil) + 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", + })} + }), nil) + 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: 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 = pipeline.ErrorPolicyOff + c.Pipeline.Inbound.Plugins = []config.PluginEntry{e} + }), nil) + 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", nil) + 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", nil) + 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", nil) + 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) + } +} + +// 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", nil) + 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", nil) + 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)} + }), nil) + 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"}, + })}, + }, + { + // 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", + "jwks_url": "https://idp.example.com/realms/r/protocol/openid-connect/certs", + "audience": "svc", + })}, + }, + { + 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, nil) + 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..bc3af7eec --- /dev/null +++ b/authbridge/authlib/praxis/praxis.go @@ -0,0 +1,1002 @@ +// 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" + "github.com/rossoctl/cortex/authbridge/authlib/pipeline" + "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) + } + + // ── 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" { + 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 + } + // 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) + 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. + // + // 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 != "" && dir == directionInbound { + 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 { + // 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 + 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. +// 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 + } + 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..3479ccb23 --- /dev/null +++ b/authbridge/authlib/praxis/praxis_test.go @@ -0,0 +1,922 @@ +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) + } +} + +// 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. +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-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-praxis/Dockerfile b/authbridge/cmd/authbridge-praxis/Dockerfile new file mode 100644 index 000000000..58caf9b0a --- /dev/null +++ b/authbridge/cmd/authbridge-praxis/Dockerfile @@ -0,0 +1,211 @@ +# 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 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) ───────── +# +# 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. +# +# 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 + +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 +# 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; 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 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}" \ + && 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, +# OAuth delegator). Build only the server crate; the workspace's test and +# benchmark members are not needed for the binary. +# +# --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 + +# 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 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\ +filter_chains:\n\ + - name: c\n\ + filters:\n\ + - filter: policy\n\ + config_path: /probe-policy.yaml\n\ + require_protocol_metadata: false\n\ + - filter: static_response\n\ + status: 200\n' > /probe.yaml \ + && 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 \ + && 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 ─────────────────── +# +# `-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..9cdabe5ff --- /dev/null +++ b/authbridge/cmd/authbridge-praxis/entrypoint.sh @@ -0,0 +1,96 @@ +#!/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}" + +# 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 +# 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..b39607c23 --- /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.1 // 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..c9904f4b2 --- /dev/null +++ b/authbridge/cmd/authbridge-praxis/go.sum @@ -0,0 +1,91 @@ +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/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= +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..cb3ea2a91 --- /dev/null +++ b/authbridge/cmd/authbridge-praxis/main.go @@ -0,0 +1,335 @@ +// 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" + "path/filepath" + + "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)") + 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() + + 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, *audienceFile); 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, audienceFile string) error { + res, pol, err := praxis.ConvertWithPolicy(cfg, policyPath, + &praxis.PolicyOptions{AudienceFile: audienceFile}) + 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) + } + 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) + } + + data, err := praxis.RenderResult(res, outPath) + if err != nil { + return fmt.Errorf("rendering config: %w", err) + } + if err := writeFileAtomic(outPath, data); 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 +} + +// 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 +} 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 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 )