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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions authbridge/authlib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ A pure Go library providing reusable building blocks for JWT validation, OAuth 2

| Package | Purpose |
|---------|---------|
| `contracts/` | Tiny, stable vocabulary plugins depend on without importing each other — role constants (`RoleUser`, `RoleAssistant`, …), the `ContentSource` / `Fragment` shape for guardrail-parser interop, and claim constants (`ClaimAuthorizationHeader`, …) for `PluginCapabilities.Claims` mutex declarations. |
| `bypass/` | Path pattern matcher for public endpoints (health, agent card). Any inbound gate plugin (jwt-validation, SAML, mTLS) can use it. |
| `routing/` | Host-to-audience router with glob pattern matching. Used by token-exchange; future routed plugins are expected to reuse it. |
| `auth/` | Composition layer: `HandleInbound` + `HandleOutbound` — used internally by the `jwt-validation` and `token-exchange` plugins. Lingering in authlib/ for now; plugin-internal in practice. |
Expand Down
29 changes: 29 additions & 0 deletions authbridge/authlib/contracts/claims.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package contracts

// Claim strings for PluginCapabilities.Claims. A claim is a semantic
// resource that exactly one plugin per chain owns; two plugins
// declaring the same claim cause plugins.Build to fail at startup.
//
// Plugin authors reference these constants instead of string literals
// so typos are compile errors and the canonical set is greppable.
// Third-party plugins that need a claim not listed here may declare
// their own arbitrary string — the framework enforces uniqueness of
// whatever it sees, not "must be from the list" — but won't benefit
// from typo safety until the claim is upstreamed here.
//
// Resist speculative additions. Each constant is a small
// hard-to-deprecate contract. Adding a new claim should be driven by
// a concrete use case where two plugins would conflict in practice.
// Upstream a new constant in a follow-up PR when a claim stabilizes,
// with a godoc paragraph explaining what the resource is and which
// in-tree plugins claim it.

// ClaimAuthorizationHeader is the exclusive right to replace the
// outbound Authorization header. token-exchange and token-broker
// both claim this; they cannot coexist in the same outbound chain
// because the one that runs second would silently clobber the
// first's work.
//
// A future SPIFFE-exchanger or Keycloak-flavored gate plugin that
// also rewrites Authorization should declare this claim.
const ClaimAuthorizationHeader = "authorization_header"
47 changes: 47 additions & 0 deletions authbridge/authlib/pipeline/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,53 @@ type PluginCapabilities struct {
//
// Deprecated: use ReadsBody. Will be removed in a future release.
BodyAccess bool

// Requires names plugins that MUST be present in the same chain
// AND appear earlier (lower index). Matches are case-sensitive
// plugin Name() strings. A missing or misordered name causes
// plugins.Build to fail at startup.
//
// Use Requires when the plugin hardcodes access to a specific
// other plugin's extension fields — e.g., a tool-allowlist plugin
// that reads pctx.Extensions.MCP.Params["name"] declares
// Requires: []string{"mcp-parser"}. If the plugin instead reads
// through pctx.ContentSources() and works against any parser,
// see RequiresAny.
Requires []string

// RequiresAny names plugins of which AT LEAST ONE must be present
// in the same chain, and each named plugin that IS present must
// appear earlier. Missing-all-of-them or misordered-any-of-them
// causes plugins.Build to fail at startup.
//
// Use RequiresAny for protocol-agnostic plugins that read through
// pctx.ContentSources(). Example: a PII scrubber that consumes
// fragments from whatever parsers are wired in declares
// RequiresAny: []string{"a2a-parser", "mcp-parser", "inference-parser"}.
// That way a chain with no parsers fails loud instead of running
// the guardrail as silent dead code.
RequiresAny []string

// After names plugins that, IF present in the same chain, must
// appear earlier. Unlike Requires/RequiresAny, a missing name is
// not an error — After is a soft ordering hint. Useful for
// plugins that benefit from earlier state being populated but
// degrade gracefully without it.
After []string

// Claims declares semantic resources the plugin takes exclusive
// ownership of. Within a single chain, at most one plugin may
// declare any given claim string; two plugins with an overlapping
// claim cause plugins.Build to fail at startup.
//
// Claim strings are arbitrary but authors should prefer the
// constants in authlib/contracts/ (e.g. contracts.ClaimAuthorizationHeader)
// so typos are compile errors and the canonical set is greppable.
// Third-party plugins may declare their own strings; the framework
// enforces uniqueness of whatever it sees, not "must be from the
// list." See authlib/contracts/claims.go for the canonical
// vocabulary.
Claims []string
}

// Normalize applies compatibility rules to a PluginCapabilities:
Expand Down
41 changes: 41 additions & 0 deletions authbridge/authlib/plugins/plugins_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
// main.go uses — ensures Build("jwt-validation") / Build("token-exchange")
// resolve during tests.
jwtvalidation "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/jwtvalidation"
_ "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/tokenbroker"
tokenexchange "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/tokenexchange"
)

Expand Down Expand Up @@ -151,3 +152,43 @@ func TestBuild_ConfigureError(t *testing.T) {
t.Errorf("error %q does not name the offending plugin", err)
}
}

// TestBuild_TokenExchangeAndTokenBroker_ConflictingClaims exercises the
// concrete case from issue #398: configuring both token-exchange and
// token-broker on the same outbound chain is now rejected at Build
// time because they both claim ClaimAuthorizationHeader.
func TestBuild_TokenExchangeAndTokenBroker_ConflictingClaims(t *testing.T) {
// Configure both with valid per-plugin config so the
// relationship check is what fails (not some earlier Configure
// error). token-broker requires broker_url; token-exchange
// requires the keycloak_url / keycloak_realm pair.
_, err := plugins.Build([]config.PluginEntry{
{
Name: "token-exchange",
Config: []byte(`{
"keycloak_url": "http://keycloak.example",
"keycloak_realm": "test",
"default_policy": "passthrough",
"identity": {"type": "client-secret"}
}`),
},
{
Name: "token-broker",
Config: []byte(`{"broker_url": "http://broker.example"}`),
},
})
if err == nil {
t.Fatal("expected relationship conflict error for token-exchange + token-broker")
}
msg := err.Error()
for _, want := range []string{
"token-exchange",
"token-broker",
"authorization_header",
"configure only one",
} {
if !strings.Contains(msg, want) {
t.Errorf("error %q does not mention %q", err, want)
}
}
}
117 changes: 117 additions & 0 deletions authbridge/authlib/plugins/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package plugins
import (
"fmt"
"sort"
"strings"
"sync"

"github.com/kagenti/kagenti-extensions/authbridge/authlib/config"
Expand Down Expand Up @@ -123,6 +124,122 @@ func Build(entries []config.PluginEntry, opts ...pipeline.Option) (*pipeline.Pip
ps = append(ps, p)
policies = append(policies, e.OnError.Resolved())
}
if err := validateRelationships(ps); err != nil {
return nil, err
}
opts = append(opts, pipeline.WithPolicies(policies...))
return pipeline.New(ps, opts...)
}

// validateRelationships checks every plugin's Requires / RequiresAny /
// After / Claims declarations against the chain it's about to run in.
// Collects all errors across the chain into one joined error rather
// than short-circuiting on the first — friendlier for operators
// iterating on a freshly-edited YAML.
//
// Semantics:
//
// - Requires: every named plugin must appear at a lower index in
// the chain. Missing or misordered is an error.
// - RequiresAny: at least one named plugin must appear at a lower
// index. Any named plugin that IS present must also be at a
// lower index.
// - After: if a named plugin is present, it must appear at a lower
// index. Silent if the named plugin is absent.
// - Claims: at most one plugin per unique claim string across the
// entire chain.
//
// Each rule loop uses the per-plugin Name() as the identity key. Case-
// sensitive (Go default). If a plugin name is duplicated in a chain
// (rare — requires config.PluginEntry.ID differentiation), the
// earliest-occurrence index is authoritative for position checks.
func validateRelationships(ps []pipeline.Plugin) error {
if len(ps) == 0 {
return nil
}
// Build a name->first-occurrence-index map once.
positions := make(map[string]int, len(ps))
for i, p := range ps {
if _, seen := positions[p.Name()]; !seen {
positions[p.Name()] = i
}
}

var errs []string

for i, p := range ps {
caps := p.Capabilities().Normalize()

// Requires — hard AND with ordering.
for _, req := range caps.Requires {
j, present := positions[req]
switch {
case !present:
errs = append(errs, fmt.Sprintf(
"plugin %q requires %q earlier in the chain, but %q is not configured",
p.Name(), req, req))
case j >= i:
errs = append(errs, fmt.Sprintf(
"plugin %q requires %q earlier in the chain, but %q appears at position %d (this plugin is at %d)",
p.Name(), req, req, j, i))
}
}

// RequiresAny — hard OR with ordering.
if len(caps.RequiresAny) > 0 {
anyPresentAndEarlier := false
for _, req := range caps.RequiresAny {
j, present := positions[req]
if !present {
continue
}
if j >= i {
// Present but misordered — report per-offender.
errs = append(errs, fmt.Sprintf(
"plugin %q lists %q under RequiresAny; %q must appear earlier (found at position %d, this plugin is at %d)",
p.Name(), req, req, j, i))
continue
}
anyPresentAndEarlier = true
}
if !anyPresentAndEarlier {
errs = append(errs, fmt.Sprintf(
"plugin %q requires at least one of %v earlier in the chain, but none are configured",
p.Name(), caps.RequiresAny))
}
}

// After — soft ordering.
for _, name := range caps.After {
j, present := positions[name]
if !present {
continue
}
if j >= i {
errs = append(errs, fmt.Sprintf(
"plugin %q declares After %q, but %q appears at position %d (this plugin is at %d); reorder so %q runs first",
p.Name(), name, name, j, i, name))
}
}
}

// Claims — chain-wide aggregation.
claimOwner := make(map[string]string, len(ps))
for _, p := range ps {
caps := p.Capabilities().Normalize()
for _, claim := range caps.Claims {
if existing, taken := claimOwner[claim]; taken && existing != p.Name() {
errs = append(errs, fmt.Sprintf(
"plugins %q and %q both claim %q; configure only one of them on this chain",
existing, p.Name(), claim))
continue
}
claimOwner[claim] = p.Name()
}
}

if len(errs) == 0 {
return nil
}
return fmt.Errorf("plugin relationship validation failed:\n - %s", strings.Join(errs, "\n - "))
}
Loading
Loading