Skip to content

feat(pipeline): Structured rejections + lifecycle hooks + plugin spec docs#377

Merged
huang195 merged 4 commits into
rossoctl:mainfrom
huang195:feat/pipeline-spec-doc
May 7, 2026
Merged

feat(pipeline): Structured rejections + lifecycle hooks + plugin spec docs#377
huang195 merged 4 commits into
rossoctl:mainfrom
huang195:feat/pipeline-spec-doc

Conversation

@huang195

@huang195 huang195 commented May 6, 2026

Copy link
Copy Markdown
Member

Summary

Hardens the plugin-pipeline contract ahead of exposing it to third-party plugin authors. Three landing points:

  1. Rejection contract (breaking for anyone manually constructing Action{Reject, ...}): replaces flat Action.Status/Action.Reason with a structured *Violation, adds default body synthesis, custom headers/body overrides, CPEX-aligned fields (Code, Reason, Description, Details, PluginName), and helper constructors (Deny, DenyStatus, DenyWithDetails, Challenge, RateLimited).
  2. Plugin lifecycle hooks (additive, opt-in): optional Initializer / Shutdowner interfaces with Pipeline.Start(ctx) / Pipeline.Stop(ctx) driving them. Existing plugins are unaffected — the interfaces are opt-in via type-assertion.
  3. Docs: the full pipeline spec at authbridge/authlib/pipeline/README.md now covers both new surfaces, with a worked RateLimiter example exercising Init, Shutdown, and the RateLimited helper. The cpex-bridge integration spec in the same file got its CPEX↔authbridge mapping table updated — the two Violation shapes are now near-isomorphic. Also includes a new abctl walkthrough at authbridge/demos/weather-agent/demo-with-abctl.md.

Motivated by the plugin-interface gap analysis discussed in-session; fulfills must-fix #1 (richer rejection) and #3 (lifecycle hooks) from that list.

New plugin shape (what this enables)

type ContentFilter struct {
    model  *classifier
    metric prometheus.Counter
}

// Lifecycle: safe place to load model + register metrics before traffic flows.
func (p *ContentFilter) Init(ctx context.Context) error {
    p.model = classifier.Load("/models/pii-v2")
    p.metric = prometheus.NewCounter(...)
    return prometheus.DefaultRegisterer.Register(p.metric)
}

func (p *ContentFilter) OnResponse(ctx context.Context, pctx *pipeline.Context) pipeline.Action {
    if p.model.ContainsPII(pctx.ResponseBody) {
        p.metric.Inc()
        // Rejection: structured reason + details, plus automatic plugin-name
        // attribution in the response body ("plugin":"content-filter").
        return pipeline.DenyWithDetails("policy.content-blocked",
            "Response redacted due to PII",
            map[string]any{"rule": "no_pii", "classifier_version": "v2"})
    }
    return pipeline.Action{Type: pipeline.Continue}
}

func (p *ContentFilter) Shutdown(ctx context.Context) error { return p.model.Close() }

Previously impossible without sync.Once-inside-OnRequest and goroutine leaks at shutdown.

Behaviors fixed / improved

Before After
Each listener hardcoded the error code ("unauthorized", "response_blocked", etc.) regardless of plugin intent Plugin sets Violation.Code; listener passes through
TestExtProc_Outbound_Deny asserted 503 because the listener forced it, hiding the plugin's actual 401 Plugin's status (401 for "no token available") flows through — test now asserts 401
Plugin-name was lost by the time the body reached the client Violation.PluginName auto-stamped by pipeline from Plugin.Name(); default body includes "plugin": "<name>"
Custom headers (WWW-Authenticate, Retry-After) had nowhere to go All four listeners preserve Violation.Headers through their respective wire formats
No place for a plugin to load a model, warm a cache, or register metrics Init(ctx) called before listeners accept traffic, fail-fast with named plugin
No place for a plugin to flush / close on SIGTERM Shutdown(ctx) called after listeners drain, reverse order, best-effort

Test plan

  • go test ./... in authlib — all green (18 new tests across action + lifecycle)
  • go test ./... in cmd/authbridge — all green; one test assertion corrected to reflect the now-correct pass-through status behavior
  • go test ./... in cmd/abctl — unaffected, all green
  • Built authbridge-envoy image locally (podman build -f cmd/authbridge/Dockerfile), loaded into kind, rolled the weather-agent pod, verified pod reaches Ready and chat requests flow through unchanged

Files changed

File Purpose
pipeline/action.go New Action/Violation shape + 5 helpers + codeToStatus + Render()
pipeline/action_test.go 13 tests
pipeline/plugin.go Added Initializer, Shutdowner optional interfaces
pipeline/pipeline.go Start(ctx) / Stop(ctx); internal cancellation uses Deny(); auto-stamps PluginName
pipeline/pipeline_test.go 5 new lifecycle tests + migration of existing tests to new shape
pipeline/README.md §5 rewritten, §6 extended, §10 CPEX mapping updated, §12 versioning entries, §13 cross-refs
plugins/jwtvalidation.go Uses DenyStatus with status→code derivation
plugins/tokenexchange.go Same shape; picks between upstream.token-exchange-failed and policy.forbidden
plugins/plugins_test.go Updated 4 assertions to Violation.Render()
listener/extproc/server.go rejectFromAction helper preserves headers via HeaderMutation.SetHeaders
listener/extproc/server_test.go One assertion corrected to reflect correct pass-through behavior
listener/extauthz/server.go deniedFromAction helper for the DeniedResponse path
listener/forwardproxy/server.go writeRejection helper
listener/reverseproxy/server.go responseRejectedError now carries the full Action; writeRejection helper
cmd/authbridge/main.go Start(initCtx) after pipeline construction; Stop(shutdownCtx) in SIGTERM path

Related

Assisted-By: Claude (Anthropic AI) noreply@anthropic.com

huang195 added 3 commits May 6, 2026 16:17
Aimed at both internal AuthBridge contributors and external plugin-framework
teams (e.g. ContextForge/CPEX) that want to integrate a sub-pipeline engine as
a single AuthBridge bridge plugin.

Covers:
- Plugin interface (Name, Capabilities, OnRequest, OnResponse)
- pipeline.Context full surface (fields, ownership, lifetime)
- Named extension slots (MCP, A2A, Inference, Security, Delegation) +
  GetState[T]/SetState[T] generic helpers for plugin-private state
- Action / capability wiring with startup validation
- Session store + SessionEvent as the observability side-channel
- Pipeline vs listener boundary (what the contract does NOT own: body
  buffering negotiation, JWT issuance, session writes, SSE streaming)
- Worked native-plugin examples
- §10 Integration spec for bridge plugins, with AuthBridge<->CPEX slot
  mapping, direction+phase hook-name encoding, capability declaration
  strategy, and 'must not' list
- §11 Open questions for cross-team iteration

Also extends authlib/README.md to index the pipeline, session, sessionapi,
plugins, and observe packages which were previously undocumented at the
module level.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
Walk a new user from 'Kagenti installed, weather agent deployed' through
building abctl, port-forwarding :9094, sending chat messages via the UI,
and using the TUI to observe:
- Session buckets with live event counts and token totals
- The IDENTITY banner summarizing caller subject/client/scopes
- Inbound and outbound event pairing with request<->response connectors
- Phase-filtered event detail JSON (request rows show only request
  fields; response rows show only response fields)
- Yank to /tmp for offline analysis
- Pipeline pane with per-plugin event counts for the current session
- Live SSE streaming, rate indicator, filter, and pause

Includes a troubleshooting table for common states and links to related
demos (token-exchange, tool-side AuthBridge) and the pipeline spec.

demo-ui.md gets a cross-link so the new walkthrough is discoverable from
the getting-started demo.

Signed-off-by: Hai Huang <huang195@gmail.com>
Two interface improvements that were blocking third-party plugin authoring
(items rossoctl#1 and rossoctl#3 from the authbridge plugin-pipeline must-fix list). Both
lift patterns from CPEX's PluginViolation + init/shutdown model.

1) Rejection contract (breaking)

Replace Action{Type: Reject, Status, Reason} with Action{Violation *}.
Violation carries CPEX-aligned structured fields (Code, Reason,
Description, Details, PluginName) plus optional HTTP-rendering hints
(Status, Body, BodyType, Headers). Default body when Body is nil:

  {
    "error":       "auth.missing-token",
    "message":     "Bearer token required",
    "description": "No Authorization header present",
    "plugin":      "jwt-validation",
    "details":     {"realm": "kagenti"}
  }

Helper constructors land the common cases in one line:

  pipeline.Deny("auth.invalid-token", "expired")
  pipeline.DenyStatus(451, "policy.forbidden", "legal")
  pipeline.DenyWithDetails("policy.rate-limited", "quota hit", details)
  pipeline.Challenge("kagenti", "authz required")  // 401 + WWW-Authenticate
  pipeline.RateLimited(30*time.Second, "", "slow")  // 429 + Retry-After

A codeToStatus table maps well-known codes to HTTP status so plugins
skip Status when the default is right. StatusFromCode("unknown") == 500.

Behaviors fixed:
- Plugin controls the error code. Previously each listener hardcoded it
  (extproc: "unauthorized" / "token_acquisition_failed" / "response_blocked").
- Plugin controls status. TestExtProc_Outbound_Deny now asserts 401
  (what tokenexchange actually returns for "no token available") instead
  of the hardcoded 503 the listener used to force.
- PluginName auto-stamped from Plugin.Name() by the pipeline, so support
  tickets trace to the offending plugin from the response body alone.
- Listeners (extproc/extauthz/forwardproxy/reverseproxy) preserve custom
  headers (WWW-Authenticate, Retry-After, etc.) through their wire
  formats. Before: headers were dropped.

Migration: the in-tree plugins (jwtvalidation, tokenexchange) now use
DenyStatus. Parsers (a2a/mcp/inference) never reject — unchanged. No
external plugins known at this time.

2) Lifecycle hooks (additive, opt-in)

New optional interfaces plugins may implement:

  type Initializer interface { Init(ctx context.Context) error }
  type Shutdowner  interface { Shutdown(ctx context.Context) error }

Pipeline.Start iterates plugins in declaration order, calls Init on
each that implements Initializer, fails fast on first error with the
offending plugin's name wrapped. Pipeline.Stop iterates in reverse
order, calls Shutdown on each Shutdowner, logs and continues past
individual errors so every plugin gets a chance to flush.

Host wiring in cmd/authbridge/main.go: Start after pipeline
construction (60s bounded ctx) and before listeners accept traffic;
Stop after listeners drain on SIGTERM (15s window).

Existing plugins don't implement either interface — they're skipped
via type-assertion, zero churn. Enables non-trivial plugin patterns
that previously required sync.Once-inside-OnRequest and leaky
goroutine cleanup.

Tests:
- 13 tests cover Violation.Render (default body, custom body, nil
  receiver, header clone, code-to-status fallback) + helper constructors.
- 5 tests cover Start/Stop (order, fail-fast on Init error, best-effort
  Shutdown, reverse-order Shutdown, no-op when no plugins implement).
- Full regression: authlib + cmd/authbridge + cmd/abctl all green.

Docs:
- pipeline/README.md §5 rewritten: new Action/Violation shape, default
  body example, helper list, code-to-status pointer.
- pipeline/README.md §6 extended: Start/Stop methods, lifecycle
  contracts, worked RateLimiter example using both Init+Shutdown and
  the RateLimited helper.
- pipeline/README.md §10 (cpex-bridge integration spec) updated:
  CPEX Violation -> authbridge Violation mapping is now near-isomorphic.
- pipeline/README.md §12 records the Action breaking change and the
  lifecycle addition in the pre-1.0 interface history.

Signed-off-by: Hai Huang <haih@us.ibm.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
- pipeline/README.md: remove the CPEX integration section and CPEX
  references scattered through the spec so the doc focuses on
  AuthBridge's own plugin contract.
- weather-agent/demo-with-abctl.md: replace fabricated plugin names
  (session-recorder, route-resolver) in the Pipeline pane mock with
  the real pipeline composition, fix the BODY column rendering
  (yes/no, not a checkmark), fix the divider row, and add a
  prerequisite step that enables the a2a/mcp/inference parsers --
  without which the PROTO and TOKENS columns shown later in the
  walkthrough stay empty.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>

@pdettori pdettori left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Well-structured PR that hardens the plugin-pipeline contract with a rich rejection model (Violation struct, helpers, code-to-status table) and opt-in lifecycle interfaces. The code is clean, backward-compatible for existing plugins, and comprehensive in test coverage (18 new tests). All four listeners consistently use Render() for reject handling, eliminating the prior inconsistency where each listener hardcoded different error codes.

Areas reviewed: Go (pipeline, plugins, listeners, main.go), Docs
Commits: 4 commits, all signed-off (DCO: pass)
CI status: All 17 checks passing

// Callers should invoke Start after Pipeline construction (pipeline.New)
// and before the listener accepts traffic. Safe to call at most once per
// Pipeline — plugins may assume Init runs exactly once per process.
func (p *Pipeline) Start(ctx context.Context) error {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

suggestion: Start fails fast on Init error but does not call Shutdown on plugins whose Init already succeeded. If plugin A allocates resources in Init and plugin B's Init fails, A leaks. The contract is documented ("No Shutdown is invoked — the intent is hard-fail on startup, not unwind") so this is a known design choice, and since main.go calls log.Fatalf the process exits anyway (OS reclaims resources). Worth considering an unwind for a future iteration where Start callers might want to retry or recover rather than exit.

}
if action.Status != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", action.Status)
status, _, _ := action.Violation.Render(); if status != http.StatusUnauthorized {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: Multi-statement on one line (status, _, _ := action.Violation.Render(); if status != ...) is valid Go but unconventional — consider splitting to two lines for readability.

@huang195
huang195 merged commit ae3ba55 into rossoctl:main May 7, 2026
17 checks passed
@github-project-automation github-project-automation Bot moved this from Backlog to Done in Kagenti Issue Prioritization May 7, 2026
@huang195
huang195 deleted the feat/pipeline-spec-doc branch May 7, 2026 11:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Feature: Create ToolHive Webhook Extension Project

3 participants