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
17 changes: 17 additions & 0 deletions authbridge/authlib/pipeline/action.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package pipeline

// ActionType represents the result of a plugin's processing.
type ActionType int

const (
Continue ActionType = iota
Reject
)

// Action is returned by a plugin to indicate whether processing should continue
// or the request should be rejected.
type Action struct {
Type ActionType
Status int // HTTP status code (only for Reject)
Reason string // human-readable reason (only for Reject)
}
41 changes: 41 additions & 0 deletions authbridge/authlib/pipeline/context.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package pipeline

import (
"net/http"

"github.com/kagenti/kagenti-extensions/authbridge/authlib/routing"
"github.com/kagenti/kagenti-extensions/authbridge/authlib/validation"
)

// Direction indicates whether a request is inbound (caller → this agent) or
// outbound (this agent → target service).
type Direction int

const (
Inbound Direction = iota
Outbound
)

// Context is the shared state passed through the plugin pipeline.
// Plugins read and mutate fields directly — there is no separate mutation API.
type Context struct {
Direction Direction
Method string
Host string
Path string
Headers http.Header
Body []byte // nil unless at least one plugin declares BodyAccess: true

@evaline-ju evaline-ju May 1, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should the BodyAccess from plugin.go be validated somewhere? Otherwise it seems it could be declared as true but the Body still nil

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

By design, BodyAccess enforcement is the adapter layer's responsibility, not the pipeline's. The pipeline package doesn't know which deployment mode is active (ext_proc with BUFFERED vs ext_authz which can never provide body). The adapter inspects plugin capabilities at construction time and either buffers the body or fails startup if the mode can't provide it. This is covered in PR 2 when we wire the pipeline into the listener.


Agent *AgentIdentity
Claims *validation.Claims // nil before jwt-validation runs

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

a direct agent suggestion:

Claims lives here on Context. Go plugins read it directly. But the cpex-bridge plugin must translate pctx into CPEX Extensions before calling mgr.Invoke(). CPEX plugins receive Extensions, not Context — they read ext.Security.Subject.Scopes for authorization decisions.

SecurityExtension in this PR has no Subject field — only Labels, Blocked, BlockReason. So the bridge must know to copy Claims into CPEX's Security.Subject manually. If it doesn't, CPEX authorization plugins silently see no identity.

Suggestion: either add a note here that the bridge is responsible for this translation, or provide a helper method on Context that produces the CPEX-side SecurityExtension with Subject populated from Claims

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Correct — the bridge is explicitly responsible for this translation. Claims and Agent are top-level Context fields (AuthBridge's identity model), not extension slots. The bridge plugin's OnRequest will map ctx.Claims → CPEX SecurityExtension.Subject and ctx.Agent → CPEX SecurityExtension.Agent before calling mgr.Invoke().

A helper could live in the bridge package itself (e.g., func toCPEXExtensions(pctx *Context) *cpex.Extensions). I'd rather not add CPEX-aware helpers to the pipeline package — it shouldn't know about CPEX. Will add a doc note on Context.Claims pointing to bridge responsibility.

Route *routing.ResolvedRoute

Extensions Extensions
}

// AgentIdentity carries the agent's own workload identity.
type AgentIdentity struct {
ClientID string
WorkloadID string
TrustDomain string
}
104 changes: 104 additions & 0 deletions authbridge/authlib/pipeline/extensions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package pipeline

import "time"

// Extensions holds typed extension slots for plugin-to-plugin communication.
// Each slot is populated by a specific plugin and consumed by downstream plugins.
type Extensions struct {
MCP *MCPExtension
A2A *A2AExtension
Security *SecurityExtension
Delegation *DelegationExtension
Custom map[string]any
}

// MCPExtension carries parsed MCP JSON-RPC metadata.
// Exactly one of Tool, Resource, or Prompt is populated per request.
type MCPExtension struct {
Method string // JSON-RPC method: "tools/call", "resources/read", "prompts/get"
RPCID any // JSON-RPC id for request-response correlation

Tool *MCPToolMetadata
Resource *MCPResourceMetadata
Prompt *MCPPromptMetadata
}

// MCPToolMetadata is populated for tools/call requests.
type MCPToolMetadata struct {
Name string
Args map[string]any
}
Comment on lines +17 to +30

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Since the PR intends to mirror cpex CMF - I was curious how this is to be resolved with the extension as defined here https://github.com/contextforge-org/contextforge-plugins-framework/blob/main/cpex/framework/extensions/mcp.py#L51-L52 - specifically this metadata only contains the input and output schema and not arguments. Is this a gap that should be resolved with cpex? (I had this question generally for the #342 discussion comment #342 (comment))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good question. These serve different layers: CPEX's ToolMetadata carries definition-time metadata (input_schema, output_schema — the JSON Schema of what a tool accepts/returns). Our MCPToolMetadata.Args carries runtime arguments (what the caller actually sent in this request).

For policy decisions, we need the runtime args — e.g., "is the caller passing a SQL query to execute_sql?". The schema is useful for validation ("are these args well-formed?") but that's the MCP server's job, not the proxy's.

The bridge translation would map our Args into whatever field CPEX expects for runtime arguments. If CPEX's ToolMetadata truly only has schema and no args field, that might be a gap on their side worth raising — a guardrails plugin needs the actual arguments to inspect, not just the schema.


// MCPResourceMetadata is populated for resources/read requests.
type MCPResourceMetadata struct {
URI string
}

// MCPPromptMetadata is populated for prompts/get requests.
type MCPPromptMetadata struct {
Name string
Args map[string]string
}

// A2AExtension carries parsed A2A protocol metadata.
type A2AExtension struct {
TaskID string
Method string // "tasks/send", "tasks/get", etc.
Parts []A2APart
}

// A2APart represents a message part in an A2A request.
type A2APart struct {
Type string // "text", "file", "data"
Content string
}

// SecurityExtension carries guardrail output.
// Caller identity is already in ctx.Agent and ctx.Claims — this slot is only
// for downstream signals from content-inspection plugins.
type SecurityExtension struct {
Labels []string
Blocked bool

@evaline-ju evaline-ju Apr 30, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If multiple writers are allowed to write to the same slot, it seems this Blocked could be changed by a later guardrail or silently overwritten?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch. The intent is tighten-only: Blocked can go false→true but not true→false. Today this is a convention — runtime enforcement (snapshotting before each plugin, rejecting weakening mutations) is planned for Phase 2 alongside the broader extension tier system. Documenting this invariant in the struct comment for now.

BlockReason string
}

// DelegationExtension tracks the token delegation chain across hops.
// The chain is append-only and unexported to prevent forgery or truncation.
type DelegationExtension struct {
chain []DelegationHop
Origin string // original caller's subject ID
Actor string // current actor's subject ID
}

// Chain returns a copy of the delegation chain. The copy prevents callers from
// mutating the backing slice (truncation, reordering, forgery).
func (d *DelegationExtension) Chain() []DelegationHop {
out := make([]DelegationHop, len(d.chain))
copy(out, d.chain)
return out
}

// Depth returns the number of hops in the delegation chain.
func (d *DelegationExtension) Depth() int {
return len(d.chain)
}

// DelegationHop represents one hop in the delegation chain.
type DelegationHop struct {
SubjectID string
Scopes []string
Timestamp time.Time
}

// AppendHop adds a hop to the delegation chain. This is the only way to extend
// the chain — direct mutation is prevented by the unexported slice.
//
// AppendHop is not safe for concurrent use. The pipeline guarantees sequential
// invocation.
func (d *DelegationExtension) AppendHop(hop DelegationHop) {
d.chain = append(d.chain, hop)
if d.Origin == "" {
d.Origin = hop.SubjectID
}
d.Actor = hop.SubjectID
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion: AppendHop() mutates Chain, Origin, and Actor without synchronization. The pipeline is currently sequential so this is safe in practice, but if the pipeline ever supports concurrent plugin execution (mentioned in PR #342 open questions), this becomes a data race.

Consider adding a doc comment like // AppendHop is not safe for concurrent use. The pipeline guarantees sequential invocation. — this makes the contract explicit for future maintainers and bridge plugin authors (CPEX, etc.).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added in 68f0b59 — AppendHop now has an explicit doc comment stating it's not safe for concurrent use and that the pipeline guarantees sequential invocation.

109 changes: 109 additions & 0 deletions authbridge/authlib/pipeline/pipeline.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package pipeline

import (
"context"
"fmt"
)

// Pipeline holds an ordered list of plugins and runs them sequentially.
type Pipeline struct {
plugins []Plugin
}

// defaultSlots lists the built-in extension slot names.
var defaultSlots = map[string]bool{
"mcp": true,
"a2a": true,
"security": true,
"delegation": true,
"custom": true,
}

// Option configures pipeline construction.
type Option func(*options)

type options struct {
extraSlots []string
}

// WithSlots registers additional valid extension slot names beyond the built-in set.
// Use this when a bridge plugin (e.g., CPEX) produces extensions not in the default set.
func WithSlots(slots ...string) Option {
return func(o *options) {
o.extraSlots = append(o.extraSlots, slots...)
}
}

// New creates a Pipeline from the given plugins after validating capability wiring.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

praise: ctx.Err() check before each plugin invocation is a good defensive pattern — prevents wasted work on cancelled requests and gives a clean 499 status. Nice that this was added in the second commit as a refinement.

// Returns an error if any plugin declares a read on a slot that no earlier plugin writes.
func New(plugins []Plugin, opts ...Option) (*Pipeline, error) {
var o options
for _, opt := range opts {
opt(&o)
}
validSlots := make(map[string]bool, len(defaultSlots)+len(o.extraSlots))
for k, v := range defaultSlots {
validSlots[k] = v
}
for _, s := range o.extraSlots {
validSlots[s] = true
}
if err := validateCapabilities(plugins, validSlots); err != nil {
return nil, err
}
return &Pipeline{plugins: plugins}, nil
}

// Run executes the request phase of the pipeline sequentially.
// If any plugin returns Reject, the pipeline stops and returns that action.
func (p *Pipeline) Run(ctx context.Context, pctx *Context) Action {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

another agent suggestion taking into account the #342 comment #342 (comment)

When cpex-bridge is implemented as a Plugin in this pipeline, its OnRequest will call mgr.Invoke() and receive a ContextTable back from CPEX. Its OnResponse will need to pass that same ContextTable into a second mgr.Invoke() call.

The bridge plugin needs somewhere to store the ContextTable between the pipeline calling bridge.OnRequest() and later calling bridge.OnResponse(). The Plugin interface provides no mechanism for a plugin to carry per-request state across those two calls. The only available storage is pctx.Extensions.Custom — map[string]any.

Not a blocker for this PR, but worth noting so the bridge doesn't default to the untyped map when it's implemented.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good callout. The bridge will need to carry CPEX's ContextTable between OnRequest and OnResponse. Using Custom for this would work but loses type safety.

Better approach for the bridge PR: the bridge plugin holds a sync.Map keyed by request ID (from context or header), storing the ContextTable between phases. The pipeline already provides context.Context which can carry a request-scoped ID. This keeps the typed extensions clean and doesn't require a new pipeline-level mechanism.

Will address concretely in the bridge PR.

for _, plugin := range p.plugins {
if ctx.Err() != nil {
return Action{Type: Reject, Status: 499, Reason: "request cancelled"}
}
action := plugin.OnRequest(ctx, pctx)
if action.Type == Reject {
return action
}
}
return Action{Type: Continue}
}

// RunResponse executes the response phase in reverse order.
// The last plugin in the chain sees the response first.
func (p *Pipeline) RunResponse(ctx context.Context, pctx *Context) Action {
for i := len(p.plugins) - 1; i >= 0; i-- {
if ctx.Err() != nil {
return Action{Type: Reject, Status: 499, Reason: "request cancelled"}
}
action := p.plugins[i].OnResponse(ctx, pctx)
if action.Type == Reject {
return action
}
}
return Action{Type: Continue}
}

// validateCapabilities checks that every slot a plugin reads has been written
// by an earlier plugin in the chain.
func validateCapabilities(plugins []Plugin, validSlots map[string]bool) error {
written := make(map[string]bool)
for _, plugin := range plugins {
caps := plugin.Capabilities()
for _, slot := range caps.Reads {
if !validSlots[slot] {
return fmt.Errorf("plugin %q declares read on unknown slot %q", plugin.Name(), slot)
}
if !written[slot] {
return fmt.Errorf("plugin %q reads slot %q but no earlier plugin writes it", plugin.Name(), slot)
}
}
for _, slot := range caps.Writes {
if !validSlots[slot] {
return fmt.Errorf("plugin %q declares write on unknown slot %q", plugin.Name(), slot)
}
written[slot] = true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What should validate the slot on reading?

If there was something like

plugins := []Plugin{
      mcpParser,   // Capabilities: {Writes: ["mcp"]}
      guardrail,   // Capabilities: {Reads: ["mcp"]}
  }

this should pass validation, but if the MCP parser parses something and doesn't populate the slot on that particular request (say with a notification request instead of a tool call request), are individual (guardrail) plugin contributors going to be responsible for checking that?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yes, individual plugins are responsible for nil-checking the extension slot they read. Startup validation catches wiring errors ("you declared reads: [mcp] but no earlier plugin writes mcp"), but a parser plugin might legitimately not populate the slot on every request (e.g., a non-JSON-RPC request passes through mcp-parser without populating Extensions.MCP).

This is the same pattern as ctx.Claims being nil before jwt-validation runs — downstream plugins that read Claims must handle nil. The alternative (always populating a zero-value struct) hides the semantic difference between "not parsed yet" and "parsed but empty".

}
}
return nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion: validateCapabilities validates Reads and Writes against valid slot names, but doesn't validate BodyAccess. If a plugin declares BodyAccess: true, there's no startup check that the deployment mode can actually provide body data (e.g., ext_authz never can, per the PR #342 design doc).

This could be a follow-up — the pipeline package may not have enough deployment-mode context at construction time. Worth a TODO or noting in the design that body-access validation happens at the adapter layer.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed — body-access validation is a deployment-mode concern. The pipeline package doesn't have mode context at construction time. The adapter layer (ext_proc, reverse proxy, etc.) will inspect PluginCapabilities.BodyAccess and either configure body buffering or fail startup if the mode can't provide it. This lands in PR 2.

Loading
Loading