diff --git a/authbridge/CLAUDE.md b/authbridge/CLAUDE.md index e680e50fd..0f2a08a5d 100644 --- a/authbridge/CLAUDE.md +++ b/authbridge/CLAUDE.md @@ -112,7 +112,7 @@ with protocol-specific listeners in `cmd/authbridge/listener/`: **Configuration loading:** - YAML config with `${ENV_VAR}` expansion, mode presets, and startup validation. -- Plugin settings are local to each plugin under `pipeline.*.plugins[].config`; the runtime YAML itself only carries `mode`, `listener`, `session`, `stats`, and the pipeline composition. See [`cmd/authbridge/README.md`](cmd/authbridge/README.md) for the per-mode YAML shape and [`authlib/plugins/CONVENTIONS.md`](authlib/plugins/CONVENTIONS.md) for the per-plugin decode pattern. +- Plugin settings are local to each plugin under `pipeline.*.plugins[].config`; the runtime YAML itself only carries `mode`, `listener`, `session`, `stats`, and the pipeline composition. See [`cmd/authbridge/README.md`](cmd/authbridge/README.md) for the per-mode YAML shape and [`docs/plugin-reference.md`](docs/plugin-reference.md) for the per-plugin decode pattern. - The operator-supplied env vars (`KEYCLOAK_URL`, `KEYCLOAK_REALM`, `TOKEN_URL`, `ISSUER`, `DEFAULT_OUTBOUND_POLICY`, `CLIENT_ID`) are consumed by the default `authbridge-combined.yaml` via `${VAR}` expansion — they land inside the appropriate plugin's `config:` block rather than a top-level section. - `jwt-validation` derives `jwks_url` from `issuer` when omitted (appends `/protocol/openid-connect/certs`). - `token-exchange` derives `token_url` from `keycloak_url + keycloak_realm` when omitted (Keycloak convention). @@ -127,8 +127,8 @@ with protocol-specific listeners in `cmd/authbridge/listener/`: - `authlib/routing/` -- Host-to-audience route resolver (used internally by `token-exchange` plugin) - `authlib/auth/` -- `HandleInbound` + `HandleOutbound` composition; each plugin instance constructs its own `auth.Auth` from its own local config - `authlib/config/` -- Mode presets, YAML config loader, credential-file waiters, top-level (mode + listener + session) validation -- `authlib/pipeline/` -- Plugin interface + lifecycle (`Configurable`, `Initializer`, `Shutdowner`); see [`authlib/pipeline/README.md`](authlib/pipeline/README.md) -- `authlib/plugins/` -- The concrete plugins + registry; see [`authlib/plugins/CONVENTIONS.md`](authlib/plugins/CONVENTIONS.md) for the per-plugin config convention +- `authlib/pipeline/` -- Plugin interface + lifecycle (`Configurable`, `Initializer`, `Shutdowner`); see [`docs/framework-architecture.md`](docs/framework-architecture.md) +- `authlib/plugins/` -- The concrete plugins + registry; see [`docs/plugin-reference.md`](docs/plugin-reference.md) for the per-plugin config convention ### init-iptables.sh @@ -289,7 +289,7 @@ kubectl apply -f k8s/auth-target-deployment-webhook.yaml # Target service | 15123 | Envoy | TCP | Outbound listener (iptables redirects app traffic here) | | 15124 | Envoy | TCP | Inbound listener (iptables redirects incoming traffic here) | | 9090 | authbridge | gRPC | Ext-proc server (called by Envoy) | -| 9093 | authbridge | HTTP | Stats + config inspection (`/stats`, `/config`) | +| 9093 | authbridge | HTTP | Stats + config inspection (`/stats`, `/config`, `/reload/status`) | | 9094 | authbridge | HTTP | Session events API (JSON snapshots + SSE stream) | | 9901 | Envoy | HTTP | Admin interface (bound to 127.0.0.1) | | 8080 | auth-proxy | HTTP | Example app (NOT part of sidecar) | @@ -340,7 +340,7 @@ Every event on `/v1/sessions/{id}` and `/v1/events` carries: - `at`, `direction`, `phase` — when, which side, what stage. `phase` is one of `"request"`, `"response"`, or `"denied"` (terminal denial from a pipeline plugin — typically a jwt-validation failure). - `a2a` / `mcp` / `inference` — protocol parser payloads (one at most). - `invocations` — per-plugin invocation records for every plugin that ran on the pipeline pass. Structured as `{inbound: [...], outbound: [...]}`; each entry carries `plugin`, `action` (one of 5 values — see below), `reason` (machine-stable code), and optional plugin-specific context (expected issuer, target audience, cache-hit flag, path, etc.). abctl renders one row per invocation, so operators see an explicit per-plugin timeline. -- `plugins` — escape-hatch map for plugin-specific observability. Keys are plugin names; values are the raw JSON each plugin emitted. Unknown plugins render as opaque JSON in abctl. See `authlib/plugins/CONVENTIONS.md` "Emitting session events" for the producer contract. +- `plugins` — escape-hatch map for plugin-specific observability. Keys are plugin names; values are the raw JSON each plugin emitted. Unknown plugins render as opaque JSON in abctl. See [`docs/plugin-reference.md`](docs/plugin-reference.md#emitting-session-events) for the producer contract. - `identity`, `host`, `statusCode`, `error`, `durationMs` — request-level context. ### Invocation action vocabulary @@ -357,6 +357,8 @@ Every plugin emits one of these 5 action values per invocation, so operators can Use `reason` to discriminate within an action — e.g. `skip/path_bypass` vs `skip/no_matching_route` tell different stories at the detail-pane level but both scan as "skip" in the at-a-glance timeline. +> **Producer-side contract:** the authoritative definition of the 5-value vocabulary, the `Invocation` struct fields, and which diagnostic fields each plugin type populates lives in [`docs/plugin-reference.md`](docs/plugin-reference.md#emitting-session-events). Edit that file when the vocabulary changes; this table is the consumer-side summary. + ### Gotcha: denied requests Rejected requests (401 / 503) land as `phase: "denied"` events in `/v1/sessions` when at least one pipeline plugin appended an Invocation before rejecting. If you're debugging an unauthorized-access pattern, the default-session bucket (`GET /v1/sessions/default`) is where denial events aggregate. @@ -365,6 +367,27 @@ Rejected requests (401 / 503) land as `phase: "denied"` events in `/v1/sessions` Set `session.enabled: false` in the runtime config to turn off the store (and implicitly the API). Setting `listener.session_api_addr: ""` alone is not currently supported as a selective disable — the preset refills it; if you need store-on-API-off, raise an issue. +## Config Hot-Reload (`:9093/reload/status`) + +The authbridge binary watches its config file (`/etc/authbridge/config.yaml`) via `authlib/reloader`. When the ConfigMap changes, kubelet syncs the new content into the mount (~60s), the watcher detects it, and the binary rebuilds + atomically swaps the plugin pipelines without a pod restart. In-flight requests finish on the previous pipeline; new requests go to the new one. + +**What reloads:** any plugin list change (add/remove/reorder) and any plugin `config:` subtree edit. + +**What doesn't reload (pod restart required):** `mode`, any `listener.*` address, and the session store parameters (`session.ttl`, `session.max_events`, `session.max_sessions`). + +**Bad YAML stays safe:** if Load/Validate/Build/Start fails, the active pipeline keeps serving and the error is exposed on `/reload/status` with `reloads_failed` incremented. The pod never goes unhealthy from a bad edit. + +Operator workflow: + +```sh +kubectl edit configmap authbridge-config- -n +kubectl port-forward -n deploy/ 9093:9093 & +curl http://localhost:9093/reload/status # last_success, reloads_ok, active_config_sha256 +curl http://localhost:9093/config # now-active config (ConfigProvider closure) +``` + +See [`docs/framework-architecture.md`](docs/framework-architecture.md#9-config-hot-reload) §9 for the reload lifecycle, debounce / symlink-swap handling, and the drain-window behavior. + ## Code Conventions ### Go (authlib, cmd/authbridge, demo-app) diff --git a/authbridge/authlib/README.md b/authbridge/authlib/README.md index 699a7838b..ece4ce7e5 100644 --- a/authbridge/authlib/README.md +++ b/authbridge/authlib/README.md @@ -14,15 +14,15 @@ A pure Go library providing reusable building blocks for JWT validation, OAuth 2 | `routing/` | Host-to-audience router with glob pattern matching | | `auth/` | Composition layer: `HandleInbound` + `HandleOutbound` — used internally by `jwt-validation` and `token-exchange` plugins | | `config/` | YAML config loader, mode presets, credential-file waiters, top-level validation | -| `pipeline/` | Plugin pipeline + lifecycle (`Configurable`, `Initializer`, `Shutdowner`) — see [pipeline/README.md](./pipeline/README.md) | +| `pipeline/` | Plugin pipeline + lifecycle (`Configurable`, `Initializer`, `Shutdowner`) — see [docs/framework-architecture.md](../docs/framework-architecture.md) | | `session/` | In-memory session store + `SessionSummary` aggregation, backing the `:9094` API | | `sessionapi/` | HTTP API (`/v1/sessions`, `/v1/events` SSE, `/v1/pipeline`) exposing the session store | -| `plugins/` | Built-in plugins: `jwt-validation`, `token-exchange`, `a2a-parser`, `mcp-parser`, `inference-parser`. See [plugins/CONVENTIONS.md](./plugins/CONVENTIONS.md) for the per-plugin config convention | +| `plugins/` | Built-in plugins: `jwt-validation`, `token-exchange`, `a2a-parser`, `mcp-parser`, `inference-parser`. See [docs/plugin-reference.md](../docs/plugin-reference.md) for the per-plugin config convention | | `observe/` | OTEL + metrics helpers | ## Usage -Plugins own their own configuration and construct any `auth.Auth` they need internally from their per-plugin config (see [plugins/CONVENTIONS.md](./plugins/CONVENTIONS.md)). Host processes (cmd/authbridge) just load the YAML, call `plugins.Build`, run the pipelines, and let each plugin handle its own dependencies. +Plugins own their own configuration and construct any `auth.Auth` they need internally from their per-plugin config (see [docs/plugin-reference.md](../docs/plugin-reference.md)). Host processes (cmd/authbridge) just load the YAML, call `plugins.Build`, run the pipelines, and let each plugin handle its own dependencies. ```go import ( diff --git a/authbridge/authlib/config/config.go b/authbridge/authlib/config/config.go index c13a39bb3..effda9173 100644 --- a/authbridge/authlib/config/config.go +++ b/authbridge/authlib/config/config.go @@ -14,8 +14,8 @@ import ( // // Plugin-specific settings (inbound JWT validation, outbound token // exchange, identity, bypass paths, routes) live inside their -// respective entries under Pipeline.* now — see the plugin README at -// authbridge/authlib/plugins/CONVENTIONS.md for how each plugin +// respective entries under Pipeline.* now — see the plugin reference at +// authbridge/docs/plugin-reference.md for how each plugin // declares its own config schema and defaults. type Config struct { Mode string `yaml:"mode" json:"mode"` // "envoy-sidecar", "waypoint", "proxy-sidecar" @@ -70,7 +70,7 @@ type PipelineStageConfig struct { // full form ({name, id, config}). The short form keeps existing pipeline // configs parsing unchanged; the long form is what plugins that // implement pipeline.Configurable actually need. See -// authbridge/authlib/plugins/CONVENTIONS.md for the convention plugins +// authbridge/docs/plugin-reference.md for the convention plugins // follow when decoding Config. // // Config is captured as a raw subtree via json.RawMessage so the plugin diff --git a/authbridge/authlib/config/presets.go b/authbridge/authlib/config/presets.go index b1d10b346..0359046f0 100644 --- a/authbridge/authlib/config/presets.go +++ b/authbridge/authlib/config/presets.go @@ -2,7 +2,7 @@ package config // ApplyPreset fills in mode-specific defaults for listener addresses. // Plugin-specific defaults live inside each plugin's Configure (see -// authbridge/authlib/plugins/CONVENTIONS.md); the runtime config no +// authbridge/docs/plugin-reference.md); the runtime config no // longer shapes plugin behavior. func ApplyPreset(cfg *Config) { switch cfg.Mode { diff --git a/authbridge/authlib/config/resolve.go b/authbridge/authlib/config/resolve.go index c49d469c5..78dc970e0 100644 --- a/authbridge/authlib/config/resolve.go +++ b/authbridge/authlib/config/resolve.go @@ -1,7 +1,7 @@ // Package config's resolve.go previously constructed a shared // auth.Config + auth.Auth for all plugins to share. With per-plugin // configuration, that responsibility moved into each plugin's Configure -// (see authbridge/authlib/plugins/CONVENTIONS.md), and this file is now +// (see authbridge/docs/plugin-reference.md), and this file is now // just the shared credential-file waiters that multiple plugins need // when they share a file path (e.g. /shared/client-id.txt used by both // jwt-validation's audience_file and token-exchange's client_id_file). diff --git a/authbridge/authlib/go.mod b/authbridge/authlib/go.mod index d59b9aa8e..9d9f136c9 100644 --- a/authbridge/authlib/go.mod +++ b/authbridge/authlib/go.mod @@ -12,6 +12,7 @@ require ( require ( github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/goccy/go-json v0.10.3 // indirect github.com/lestrrat-go/blackmagic v1.0.3 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect diff --git a/authbridge/authlib/go.sum b/authbridge/authlib/go.sum index 5f280e3f9..d338f51ea 100644 --- a/authbridge/authlib/go.sum +++ b/authbridge/authlib/go.sum @@ -3,6 +3,8 @@ 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.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 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.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= diff --git a/authbridge/authlib/observe/statserver.go b/authbridge/authlib/observe/statserver.go index 41623b0a4..f8f187438 100644 --- a/authbridge/authlib/observe/statserver.go +++ b/authbridge/authlib/observe/statserver.go @@ -23,14 +23,43 @@ type StatServer struct { // should be cheap (a few map copies). type StatsProvider func() *auth.Stats -// NewStatServer builds the stat HTTP server. The statsProvider is -// invoked per /stats request so per-plugin counters can be aggregated -// live rather than captured at process start. -func NewStatServer(addr string, config *config.Config, statsProvider StatsProvider) *StatServer { +// ConfigProvider returns the currently-active *config.Config per +// /config request. Used so the endpoint reflects a hot-reload swap +// performed by authlib/reloader; for setups without hot-reload the +// host just wraps a captured pointer (`func() *Config { return cfg }`). +type ConfigProvider func() *config.Config + +// Option configures a StatServer at construction time. +type Option func(*statServerOpts) + +type statServerOpts struct { + reloadStatus http.Handler +} + +// WithReloadStatus registers a /reload/status handler (typically the +// Handler returned by an authlib/reloader.Reloader). Omit when hot- +// reload isn't wired up — the endpoint simply won't exist. +func WithReloadStatus(h http.Handler) Option { + return func(o *statServerOpts) { o.reloadStatus = h } +} + +// NewStatServer builds the stat HTTP server. configProvider is +// invoked per /config request and statsProvider per /stats request, +// so both reflect current state rather than a snapshot captured at +// construction. +func NewStatServer(addr string, configProvider ConfigProvider, statsProvider StatsProvider, opts ...Option) *StatServer { + var o statServerOpts + for _, opt := range opts { + opt(&o) + } + mux := http.NewServeMux() - mux.HandleFunc("/config", handleConfigFactory(config)) + mux.HandleFunc("/config", handleConfigFactory(configProvider)) mux.HandleFunc("/stats", handleStatsFactory(statsProvider)) + if o.reloadStatus != nil { + mux.Handle("/reload/status", o.reloadStatus) + } mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -41,6 +70,7 @@ func NewStatServer(addr string, config *config.Config, statsProvider StatsProvid `) @@ -55,7 +85,7 @@ func NewStatServer(addr string, config *config.Config, statsProvider StatsProvid } } -func handleConfigFactory(cfg *config.Config) func(http.ResponseWriter, *http.Request) { +func handleConfigFactory(provider ConfigProvider) func(http.ResponseWriter, *http.Request) { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") // Plugin config subtrees are captured verbatim as json.RawMessage @@ -65,7 +95,7 @@ func handleConfigFactory(cfg *config.Config) func(http.ResponseWriter, *http.Req // so we render the config as-is. If a plugin ever needs to // suppress a known-sensitive field here, it can be added to a // redaction pass in a follow-up. - err := json.NewEncoder(w).Encode(cfg) + err := json.NewEncoder(w).Encode(provider()) if err != nil { slog.Default().Info("Failed to send configuration", "err", err) } diff --git a/authbridge/authlib/observe/statserver_test.go b/authbridge/authlib/observe/statserver_test.go index 7a2718846..22b9c8ada 100644 --- a/authbridge/authlib/observe/statserver_test.go +++ b/authbridge/authlib/observe/statserver_test.go @@ -31,7 +31,7 @@ func serveMux(cfg *config.Config, stats *auth.Stats) http.Handler { // what /stats returns. Production callers pass a provider that // merges per-plugin Stats at request time; see main.go's // statsProvider closure. - s := NewStatServer(":0", cfg, func() *auth.Stats { return stats }) + s := NewStatServer(":0", func() *config.Config { return cfg }, func() *auth.Stats { return stats }) return s.server.Handler } @@ -134,14 +134,14 @@ func TestStatsEndpointValidJSON(t *testing.T) { } func TestNewStatServerSetsAddr(t *testing.T) { - s := NewStatServer(":9093", newTestConfig(), func() *auth.Stats { return auth.NewStats() }) + s := NewStatServer(":9093", func() *config.Config { return newTestConfig() }, func() *auth.Stats { return auth.NewStats() }) if s.server.Addr != ":9093" { t.Errorf("server.Addr = %q, want :9093", s.server.Addr) } } func TestNewStatServerCustomAddr(t *testing.T) { - s := NewStatServer("127.0.0.1:8888", newTestConfig(), func() *auth.Stats { return auth.NewStats() }) + s := NewStatServer("127.0.0.1:8888", func() *config.Config { return newTestConfig() }, func() *auth.Stats { return auth.NewStats() }) if s.server.Addr != "127.0.0.1:8888" { t.Errorf("server.Addr = %q, want 127.0.0.1:8888", s.server.Addr) } diff --git a/authbridge/authlib/pipeline/README.md b/authbridge/authlib/pipeline/README.md index 1b2f26a3b..f9c8f59ea 100644 --- a/authbridge/authlib/pipeline/README.md +++ b/authbridge/authlib/pipeline/README.md @@ -1,575 +1,23 @@ -# pipeline — Plugin Pipeline Specification +# pipeline -The `pipeline` package defines AuthBridge's plugin contract: how plugins are written, how they communicate through shared state, how they compose into ordered chains, and how those chains run inside each of the three listener modes (ext_proc, ext_authz, forward/reverse proxy). +Plugin pipeline types, lifecycle interfaces, and the `Context` / `Extensions` +wire shape. The full framework reference — mental model, dispatch order, +SessionEvent shape, versioning changelog — lives in +[`authbridge/docs/framework-architecture.md`](../../docs/framework-architecture.md). -This document is the reference for AuthBridge's plugin contract. It covers the interface plugins implement, the shared state they communicate through, how the pipeline composes them, and how the listener renders their decisions. +## Plugin developer docs -**Audience:** -- Go developers adding plugins to AuthBridge's native chain. -- Anyone debugging the plugin flow via `abctl` or the `:9094` session API. +- Tutorial: [`docs/plugin-tutorial.md`](../../docs/plugin-tutorial.md) +- Reference: [`docs/plugin-reference.md`](../../docs/plugin-reference.md) +- Framework architecture: [`docs/framework-architecture.md`](../../docs/framework-architecture.md) -**Scope:** -- The Go surface in `authbridge/authlib/pipeline/` and `authbridge/authlib/session/`. -- The observability contract carried by `SessionEvent` on the `:9094` API. -- What the pipeline *does* and *does not* own at the boundary with the listener. +## Package contents ---- - -## 1. Mental model - -AuthBridge intercepts HTTP traffic in two directions and runs a **separate plugin chain** for each. Each chain has two **phases** — request (headers/body going to the upstream) and response (headers/body coming back). - -``` - Inbound (caller → this agent) - ┌────────────────────────────────────────────────────┐ - │ Request phase → jwt-validation │ - │ → a2a-parser │ - │ → session-recorder (implicit) │ - │ Response phase ← a2a-parser OnResponse │ - │ ← jwt-validation OnResponse │ - └────────────────────────────────────────────────────┘ - - Outbound (this agent → target service) - ┌────────────────────────────────────────────────────┐ - │ Request phase → route-resolver │ - │ → token-exchange │ - │ → mcp-parser / inference-parser │ - │ Response phase ← mcp-parser / inference-parser │ - │ ← token-exchange OnResponse │ - └────────────────────────────────────────────────────┘ -``` - -**Key properties:** -- Plugins execute **sequentially** within a phase. -- Response phase runs plugins in **reverse order** (last plugin sees the response first — LIFO, matches middleware conventions). -- Inbound and outbound are **separate `Pipeline` instances**. A plugin that cares about both directions is registered on both. -- All state shared between plugins within one request/response cycle lives on `*pipeline.Context` (`pctx`). -- Cross-request state (per-session telemetry) lives in the `session.Store`, accessed read-only via `pctx.Session`. - ---- - -## 2. The `Plugin` interface - -```go -type Plugin interface { - Name() string - Capabilities() PluginCapabilities - OnRequest(ctx context.Context, pctx *Context) Action - OnResponse(ctx context.Context, pctx *Context) Action -} -``` - -### `Name() string` -A stable identifier. Used for logs, metrics, `GetState`/`SetState` keys (by convention), and pipeline introspection (`GET /v1/pipeline`). - -### `Capabilities() PluginCapabilities` - -```go -type PluginCapabilities struct { - Reads []string // extension slot names this plugin reads - Writes []string // extension slot names this plugin writes - BodyAccess bool // whether this plugin needs request/response body buffered -} -``` - -Declared once per plugin instance. `pipeline.New` validates that every `Read` is satisfied by an earlier plugin's `Write` — a plugin that depends on `mcp` being populated cannot be registered before `mcp-parser`. A mis-ordered registration fails fast at startup with: - -``` -plugin "guardrail" reads slot "mcp" but no earlier plugin writes it -``` - -`BodyAccess: true` on *any* plugin in a chain causes `Pipeline.NeedsBody()` to return true, which the **listener** uses to negotiate Envoy's `ProcessingMode` (BUFFERED vs HEADERS-only). Without this, the gRPC ext_proc server never asks for the body and parsers see `pctx.Body == nil`. - -### `OnRequest(ctx, pctx) Action` -Called when a request is entering the pipeline. Plugins typically read request headers / body, mutate one or more extension slots, and return `Continue` or `Reject`. - -### `OnResponse(ctx, pctx) Action` -Called after the upstream returns. `pctx.StatusCode`, `pctx.ResponseHeaders`, and `pctx.ResponseBody` are populated. Plugins typically enrich the telemetry extensions with response-side data (completion text, token usage, error code) or apply guardrails on the response content. - -Plugins that only care about the request set `OnResponse` to a no-op (`return Action{Type: Continue}`); same for response-only plugins on `OnRequest`. - ---- - -## 3. `pipeline.Context` — the shared state - -The entire surface a plugin sees: - -```go -type Context struct { - Direction Direction // Inbound | Outbound - Method string // HTTP method - Host string // :authority / Host - Path string // :path - Headers http.Header - Body []byte // nil unless a plugin declared BodyAccess: true - StartedAt time.Time // listener wall-clock at request entry - - Agent *AgentIdentity // this workload's SPIFFE / Keycloak identity - Claims *validation.Claims // inbound caller's JWT claims after jwt-validation - Route *routing.ResolvedRoute // outbound: resolved audience / token scopes - Session *SessionView // read-only view of the session bucket - - // Response-phase fields (populated by listener before RunResponse) - StatusCode int - ResponseHeaders http.Header - ResponseBody []byte - - Extensions Extensions -} -``` - -**Ownership rules:** -- Plugins **read** any field they declared in `Capabilities.Reads`. -- Plugins **write** fields they declared in `Capabilities.Writes`. By convention each extension slot has exactly one writer (the parser plugin). -- `Claims` is populated by `jwt-validation` and is read-only afterward. -- `Agent`, `Route`, `Session` are populated by the listener before `Run`. Plugins treat them as read-only. -- `ResponseBody` appears between `Run` and `RunResponse` — plugins must not read it in `OnRequest`. - -**Lifetime:** one `*Context` per HTTP transaction. Not reused across requests. Single-threaded — the pipeline guarantees sequential invocation of plugins within a phase, so plugins don't need internal locking for pctx reads/writes. - ---- - -## 4. `Extensions` — typed plugin-to-plugin communication - -```go -type Extensions struct { - MCP *MCPExtension - A2A *A2AExtension - Security *SecurityExtension - Delegation *DelegationExtension - Inference *InferenceExtension - Custom map[string]any -} -``` - -Two categories: - -### Named slots (telemetry-worthy) -MCP, A2A, Security, Delegation, Inference. These are: -- Part of the **published schema** carried on `SessionEvent` to `:9094` / `abctl`. -- Consumable by multiple downstream plugins. -- Added to the core struct only when the data has a public contract. - -Adding a named slot is an authlib-core change: edit `Extensions`, add a `SessionEventWire` field, update `snapshotXXX` helpers in the listener, and add filtering rules in `abctl`. - -### `Custom map[string]any` + `GetState[T]`/`SetState[T]` (plugin-private) -For state that's internal to a single plugin or to a bridge's sub-pipeline: - -```go -// Plugin's private state type: -type rlState struct { - TokensAtStart int - Decision string -} - -// In OnRequest: -pipeline.SetState(pctx, "rate-limiter", &rlState{TokensAtStart: 100}) - -// In OnResponse: -s := pipeline.GetState[rlState](pctx, "rate-limiter") -if s != nil { /* use s */ } -``` - -Convention: **key = plugin's Name()** so collisions across plugins don't happen. Storage is lazy (`Custom` is nil-initialized until first write). - -`GetState[T]` type-asserts and returns `nil` on mismatch instead of panicking — a plugin whose type evolves across versions degrades gracefully. - -### Built-in extension shapes - -All at `authbridge/authlib/pipeline/extensions.go`: - -```go -type MCPExtension struct { - Method string // JSON-RPC method, e.g. "tools/call" - RPCID any // JSON-RPC id (could be int or string) - Params map[string]any // request params - Result map[string]any // response result (mutually exclusive with Err) - Err *MCPError -} - -type A2AExtension struct { - Method string - RPCID any - SessionID string // contextId from the client, or server-assigned on first turn - MessageID string - TaskID string - Role string // "user" | "agent" - Parts []A2APart - FinalStatus string // response: "completed" | "failed" | "canceled" - Artifact string // response: assembled artifact text - ErrorMessage string // response: failure reason -} - -type InferenceExtension struct { - // Request side: - Model string - Messages []InferenceMessage - Temperature *float64 - MaxTokens *int - TopP *float64 - Stream bool - Tools []InferenceTool // full definition incl. parameters schema - ToolChoice any - // Response side: - Completion string - FinishReason string - PromptTokens int - CompletionTokens int - TotalTokens int - ToolCalls []InferenceToolCall -} - -type SecurityExtension struct { - Labels []string // classifier / guardrail output - Blocked bool - BlockReason string -} - -type DelegationExtension struct { - Origin string // original caller subject - Actor string // current actor subject - // chain is append-only via AppendHop; reads via Chain() -} -``` - -Mutability: **always assigned, never mutated in place** after the parser sets the slot. This guarantees that `snapshotXXX` in the listener (shallow-copy for event recording) stays correct even when OnResponse enriches the struct — the response snapshot is taken from the now-enriched pointer, but any earlier request-phase snapshot was taken of a frozen copy. - ---- - -## 5. `Action` — control flow - -```go -type Action struct { - Type ActionType // Continue | Reject - Violation *Violation // populated iff Type == Reject -} - -type Violation struct { - // Structured machine-readable error: - Code string // machine-readable, e.g. "auth.missing-token" - Reason string // short human message - Description string // longer explanation; optional - Details map[string]any // plugin-arbitrary structured context; optional - - // HTTP rendering hints — all optional; defaults from Code: - Status int // when 0, StatusFromCode(Code) is used - Body []byte // when nil, synthesized JSON - BodyType string // Content-Type for Body; defaults to application/json - Headers http.Header // merged into the response (e.g. WWW-Authenticate, Retry-After) - - // Framework-populated from Plugin.Name(); plugins leave it empty: - PluginName string -} -``` - -Returning `Reject` from `OnRequest` halts the request pipeline; from `OnResponse` halts the response pipeline. The listener calls `Violation.Render()` to produce `(status, headers, body)` and emits that as the HTTP response. The default body when `Body` is nil: - -```json -{ - "error": "auth.missing-token", - "message": "Bearer token required", - "description": "No Authorization header present", - "plugin": "jwt-validation", - "details": { "realm": "kagenti" } -} -``` - -Helper constructors cover the common cases so the reject site stays one line: - -```go -pipeline.Deny("auth.invalid-token", "token expired") -pipeline.DenyStatus(451, "policy.forbidden", "unavailable for legal reasons") -pipeline.DenyWithDetails("policy.rate-limited", "quota hit", map[string]any{ - "remaining": 0, "window": "1h", -}) -pipeline.Challenge("kagenti", "Authorization required") // 401 + WWW-Authenticate -pipeline.RateLimited(30*time.Second, "", "slow down") // 429 + Retry-After -``` - -The `Code` → HTTP-status mapping for well-known codes lives at `codeToStatus` in `action.go`; unknown codes default to 500. Plugins that need a non-default status set `Violation.Status` explicitly or use `DenyStatus`. - -There is no "soft error" channel today — a plugin that wants to fail open logs and returns `Continue`. A future iteration may add a per-plugin `on_error` policy. - ---- - -## 6. `Pipeline` — composition and execution - -```go -func New(plugins []Plugin, opts ...Option) (*Pipeline, error) -func (p *Pipeline) Run(ctx context.Context, pctx *Context) Action // request phase -func (p *Pipeline) RunResponse(ctx context.Context, pctx *Context) Action // response phase (reverse) -func (p *Pipeline) Start(ctx context.Context) error // invoke Init on Initializer plugins -func (p *Pipeline) Stop(ctx context.Context) // invoke Shutdown on Shutdowner plugins -func (p *Pipeline) Plugins() []Plugin // defensive copy -func (p *Pipeline) NeedsBody() bool // OR over all plugins' BodyAccess -``` - -`New` validates capability wiring at startup: every `Read` must be satisfied by some earlier plugin's `Write`. - -### Plugin lifecycle (`Start` / `Stop`) - -Plugins that need one-time setup (load a model, warm a cache, register metrics, spawn a background goroutine) implement the optional `Initializer` interface: - -```go -type Initializer interface { - Init(ctx context.Context) error -} -``` - -Plugins that need graceful cleanup (flush audit events, close a connection, cancel a goroutine) implement `Shutdowner`: - -```go -type Shutdowner interface { - Shutdown(ctx context.Context) error -} -``` - -Both are **optional** via Go's type-assertion idiom — a plugin that doesn't need them simply doesn't implement them, and the pipeline skips it. Existing plugins (jwt-validation, a2a-parser, mcp-parser, inference-parser, token-exchange) don't implement these; they keep working unchanged. - -The host (e.g. `cmd/authbridge/main.go`) drives the lifecycle: - -```go -// After pipeline.New, before listeners accept traffic: -initCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second) -defer cancel() -if err := inboundPipeline.Start(initCtx); err != nil { - log.Fatalf("inbound pipeline Start: %v", err) // fail-fast on bad plugin init -} -if err := outboundPipeline.Start(initCtx); err != nil { - log.Fatalf("outbound pipeline Start: %v", err) -} - -// ... serve traffic ... - -// After listeners have drained on SIGTERM: -outboundPipeline.Stop(shutdownCtx) // reverse order within each pipeline -inboundPipeline.Stop(shutdownCtx) -``` - -Semantics: -- `Start` — Init runs **in declaration order**, fails fast on the first error. The returned error names the offending plugin. No Shutdown is invoked on plugins whose Init already ran successfully — the intent is hard-fail on startup, not unwind. -- `Stop` — Shutdown runs **in reverse declaration order (LIFO)** so a plugin that depends on an earlier plugin's resources can still use them while cleaning up. Best-effort: errors from one Shutdown are logged but do not stop the sequence. Bounded by the caller's ctx deadline. - -A minimal Init/Shutdown plugin example — a rate-limiter that refreshes its quota store in the background: - -```go -type RateLimiter struct { - store *quotaStore - cancel context.CancelFunc -} - -func (p *RateLimiter) Name() string { return "rate-limiter" } -func (p *RateLimiter) Capabilities() pipeline.PluginCapabilities { return pipeline.PluginCapabilities{} } - -func (p *RateLimiter) Init(ctx context.Context) error { - p.store = newQuotaStore() - bg, cancel := context.WithCancel(context.Background()) - p.cancel = cancel - go p.store.refreshLoop(bg, 10*time.Second) // lives until Shutdown - return nil -} - -func (p *RateLimiter) Shutdown(ctx context.Context) error { - p.cancel() // stop the refresh loop - return p.store.flush(ctx) // best-effort write-back of pending counters -} - -func (p *RateLimiter) OnRequest(ctx context.Context, pctx *pipeline.Context) pipeline.Action { - if !p.store.allow(pctx) { - return pipeline.RateLimited(30*time.Second, "", "quota exceeded") - } - return pipeline.Action{Type: pipeline.Continue} -} - -func (p *RateLimiter) OnResponse(context.Context, *pipeline.Context) pipeline.Action { - return pipeline.Action{Type: pipeline.Continue} -} -``` - -### Extension slots known to the validator - -Built-in: `mcp`, `a2a`, `security`, `delegation`, `inference`, `custom`. - -**For plugins that write new slot names:** use the `WithSlots` option: - -```go -pipeline, err := pipeline.New(plugins, - pipeline.WithSlots("provenance", "risk-score")) -``` - -This tells the validator those slot names are legal, so a downstream plugin can `Capabilities.Reads = []string{"provenance"}` without being rejected as "unknown slot". - -### Execution order -- Request phase: `plugins[0].OnRequest → plugins[1].OnRequest → …` -- Response phase: `plugins[N-1].OnResponse → plugins[N-2].OnResponse → …` -- A `Reject` from any plugin halts its phase immediately. -- `ctx.Err() != nil` between plugins also halts with `Reject{Status: 499}`. - -### Concurrency model -Always sequential. No priority / mode / fire-and-forget semantics yet. This is the 80% case for auth-and-parse pipelines; richer modes would require an executor layer above the current loop. - ---- - -## 7. `Session` + `SessionEvent` — the observability side-channel - -The pipeline itself is **in-band** (plugins alter request handling). Alongside it runs an **out-of-band** observability layer: the listener snapshots `pctx` into a `SessionEvent` after each phase and appends it to a per-session bucket in the `session.Store`. This store is what powers the `:9094` HTTP API and `abctl`. - -```go -type SessionEvent struct { - SessionID string // bucket the event landed in - At time.Time - Direction Direction // inbound | outbound - Phase SessionPhase // request | response - A2A *A2AExtension // snapshot of pctx.Extensions.A2A - MCP *MCPExtension - Inference *InferenceExtension - Identity *EventIdentity // Subject, ClientID, AgentID, Scopes - StatusCode int // response phase only - Error *EventError // populated on 4xx/5xx - Host string // :authority - TargetAudience string // outbound: resolved OAuth audience - Duration time.Duration // response: wall-clock since request entry -} -``` - -**Plugins do not touch `SessionEvent` directly.** The listener records events; plugins only read `pctx.Session` (a `*SessionView`) when they want to correlate the current request with prior ones in the same conversation — e.g. a rate-limiter that counts a session's inference events. - -Wire format (`SessionEvent.MarshalJSON`) translates enums to strings and `Duration` to `DurationMs`. Round-trip stable — `json.Marshal(e) → json.Unmarshal → json.Marshal` is byte-identical. Tested at `pipeline/session_test.go:TestSessionEvent_JSONRoundTrip`. - ---- - -## 8. Boundary: pipeline vs listener - -The pipeline **does not own**: - -| Concern | Owner | Why | -|---|---|---| -| HTTP wire protocol (ext_proc gRPC, ext_authz, reverse/forward proxy) | `cmd/authbridge/listener/` | Each mode speaks a different wire; pipeline stays protocol-free | -| Body buffering negotiation (`ProcessingMode: BUFFERED`) | Listener reads `Pipeline.NeedsBody()` | Only listener can respond to the ext_proc handshake | -| JWT issuance, client registration, Keycloak admin calls | Outside the pipeline (agent sidecars / kagenti-operator) | Async concerns happening before/after any request flow | -| Session store writes (`Store.Append`) | Listener, called after each phase | Plugins see only the read-only `SessionView` | -| SSE streaming of events to abctl | `authlib/sessionapi` | Observability API, not a plugin concern | - -The pipeline **does own**: -- The `Plugin` interface contract. -- `pipeline.Context` structure and invariants. -- Validation of capability wiring at construction. -- Sequential dispatch and reject-short-circuit semantics. -- Typed extension slots and `GetState`/`SetState` helpers. -- The session-event *shape* (the listener uses it but doesn't define it). - ---- - -## 9. Writing a native plugin — a worked example - -A minimal outbound plugin that stamps an extra header on any request routed to GitHub: - -```go -package myplugins - -import ( - "context" - "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" -) - -type GitHubStamper struct{} - -func (GitHubStamper) Name() string { return "github-stamper" } - -func (GitHubStamper) Capabilities() pipeline.PluginCapabilities { - return pipeline.PluginCapabilities{ - // We don't need body buffering, and don't depend on other plugins' data. - } -} - -func (GitHubStamper) OnRequest(_ context.Context, pctx *pipeline.Context) pipeline.Action { - if pctx.Route != nil && pctx.Route.Audience == "github-tool" { - pctx.Headers.Set("x-from-authbridge", "1") - } - return pipeline.Action{Type: pipeline.Continue} -} - -func (GitHubStamper) OnResponse(_ context.Context, _ *pipeline.Context) pipeline.Action { - return pipeline.Action{Type: pipeline.Continue} -} -``` - -Registered in the outbound pipeline alongside the built-in plugins at startup in `cmd/authbridge/main.go`. - -A plugin that reads the caller's SPIFFE ID from inbound claims and records a per-session counter via `GetState`/`SetState`: - -```go -type sessionCounter struct{ N int } - -func (p *Counter) Name() string { return "turn-counter" } - -func (p *Counter) Capabilities() pipeline.PluginCapabilities { - return pipeline.PluginCapabilities{Reads: []string{"a2a"}} -} - -func (p *Counter) OnRequest(_ context.Context, pctx *pipeline.Context) pipeline.Action { - if pctx.Direction != pipeline.Inbound || pctx.Extensions.A2A == nil { - return pipeline.Action{Type: pipeline.Continue} - } - state := pipeline.GetState[sessionCounter](pctx, p.Name()) - if state == nil { - state = &sessionCounter{} - pipeline.SetState(pctx, p.Name(), state) - } - state.N++ - if state.N > 10 { - return pipeline.RateLimited(30*time.Second, - "policy.rate-limited", "per-session turn limit exceeded") - } - return pipeline.Action{Type: pipeline.Continue} -} -``` - -Plugins express rejections through a structured `Violation` carrying a -machine-readable `Code`, a short `Reason`, an optional longer -`Description`, a `Details` map for plugin-arbitrary context, and HTTP- -rendering hints (`Status`, `Body`, `BodyType`, `Headers`). Default JSON -body synthesis covers the 95% case — set the hints only when overriding. -Helper constructors (`Deny`, `DenyStatus`, `DenyWithDetails`, -`Challenge`, `RateLimited`) make the common cases one-liners. See -`action.go` for the full surface and `action_test.go` for worked -examples. - ---- - -## 10. Open questions - -- **Priority / on-error policies.** Plugins don't declare these today. If fail-open / fail-closed behavior becomes important to express per plugin, it would be added to `PluginCapabilities` (or a sibling metadata struct) and interpreted by `Pipeline`. -- **Body mutation semantics.** Today plugins generally don't rewrite `pctx.Body` or `pctx.ResponseBody`. If a plugin needs to modify the payload, we'd need a clear contract about whether downstream plugins see the modified or original bytes. -- **Execution modes.** The pipeline is sequential-only. Concurrent or fire-and-forget modes would require an executor layer; no concrete use case yet. - ---- - -## 11. Versioning - -The plugin interface is **not** semver-stable yet (AuthBridge is pre-1.0). Changes since the initial release: -- Added `BodyAccess` to `PluginCapabilities`. -- Added `WithSlots` to `New` for bridge-plugin slot registration. -- Added `GetState[T]` / `SetState[T]` generic helpers. -- Extended `A2AExtension` with response-side fields (TaskID, FinalStatus, Artifact, ErrorMessage). -- Extended `InferenceExtension` with structured tools + tool calls + TopP / ToolChoice. -- Added `SessionEvent.MarshalJSON`/`UnmarshalJSON` round-trip contract. -- **Breaking**: replaced `Action.Status`/`Action.Reason` with `Action.Violation` (see §5). Migration: use `Deny()`, `DenyStatus()`, `Challenge()`, `RateLimited()` helpers. -- Added optional `Initializer` / `Shutdowner` interfaces + `Pipeline.Start` / `Pipeline.Stop` (see §6). Existing plugins are unaffected because the interfaces are opt-in via type-assertion. - -Breaking changes will be announced in `authbridge/CHANGELOG.md` (TBD) before a 1.0 tag. - ---- - -## 12. Cross-references - -- `pipeline.go` — `Pipeline` type, `New`, `Run`, `RunResponse`, `Start`, `Stop`, `Plugins`, `NeedsBody`. -- `plugin.go` — `Plugin` interface, `PluginCapabilities`, `Initializer`, `Shutdowner`. -- `action.go` — `Action`, `ActionType`, `Violation`, helper constructors (`Deny`, `DenyStatus`, `DenyWithDetails`, `Challenge`, `RateLimited`), `StatusFromCode`. -- `context.go` — `Context`, `Direction`, `AgentIdentity`. -- `extensions.go` — named extension types + `GetState`/`SetState`. -- `session.go` — `SessionEvent`, `SessionView`, `SessionPhase`, marshalers. -- `authlib/session/` — `Store`, `SessionSummary`, ring buffer, TTL / max-events caps. -- `authlib/sessionapi/` — HTTP API (`/v1/sessions`, `/v1/events`, `/v1/pipeline`) surfacing all of the above. -- `cmd/authbridge/listener/extproc/` — reference usage for all three phases. -- `cmd/abctl/` — TUI consumer of the session API, useful as a reference integrator. +| File | Purpose | +|---|---| +| `plugin.go` | `Plugin` interface + optional `Configurable` / `Initializer` / `Shutdowner` / `Readier` | +| `context.go` | Per-request `Context` (the `pctx` each plugin sees) + `Record` / `Allow` / `Skip` / `Observe` / `Modify` / `DenyAndRecord` helpers | +| `extensions.go` | Named protocol slots (A2A / MCP / Inference), `Custom` map, `Invocations`, `/event` suffix | +| `session.go` | `SessionEvent` wire shape; the `:9094` API payload | +| `pipeline.go` | `Pipeline.Run` / `RunResponse` dispatch loop | +| `action.go` | `Action` + helpers (`Deny` / `DenyStatus` / `Challenge` / `RateLimited`) | diff --git a/authbridge/authlib/pipeline/configurable.go b/authbridge/authlib/pipeline/configurable.go index f34ffc86e..d0e5968f5 100644 --- a/authbridge/authlib/pipeline/configurable.go +++ b/authbridge/authlib/pipeline/configurable.go @@ -18,7 +18,7 @@ import "encoding/json" // - An error from Configure aborts pipeline construction and takes the // process down; do not partial-initialize and return nil. // -// See authbridge/authlib/plugins/CONVENTIONS.md for the recommended shape +// See authbridge/docs/plugin-reference.md for the recommended shape // of per-plugin config and a worked example. type Configurable interface { Configure(raw json.RawMessage) error diff --git a/authbridge/authlib/pipeline/context.go b/authbridge/authlib/pipeline/context.go index 2346993ac..842183a1d 100644 --- a/authbridge/authlib/pipeline/context.go +++ b/authbridge/authlib/pipeline/context.go @@ -88,6 +88,124 @@ type Context struct { ResponseBody []byte Extensions Extensions + + // currentPlugin and currentPhase are framework-owned fields set by + // Pipeline.Run / RunResponse around each plugin dispatch. They feed + // the Record / Allow / Skip / Observe / Modify / DenyAndRecord helper + // methods so plugin code doesn't have to repeat its own Name(), + // the phase it's in, or the direction on every Invocation literal. + // Unexported so plugins can only set them indirectly (via the framework). + currentPlugin string + currentPhase InvocationPhase +} + +// SetCurrentPlugin is called by Pipeline.Run / RunResponse immediately +// before dispatching into a plugin's OnRequest / OnResponse. It stamps +// the plugin name and phase onto pctx so the Record family of helpers +// can fill those fields without plugin-side ceremony. Reset with +// ClearCurrentPlugin after dispatch. +// +// Exported (rather than framework-private) because listeners that embed +// pctx in their own dispatch loops (not strictly via Pipeline.Run) need +// to set the same fields to get consistent Invocation attribution. +// Production plugins should never call this directly. +func (c *Context) SetCurrentPlugin(name string, phase InvocationPhase) { + c.currentPlugin = name + c.currentPhase = phase +} + +// ClearCurrentPlugin resets the framework-owned attribution fields. +// Paired with SetCurrentPlugin. +func (c *Context) ClearCurrentPlugin() { + c.currentPlugin = "" + c.currentPhase = "" +} + +// Record appends an Invocation to pctx under the current pipeline +// direction and framework-stamped plugin + phase. The author supplies +// only what's specific to this call (Action, Reason, plus any +// diagnostic fields like ExpectedIssuer, RouteHost, CacheHit); Plugin, +// Phase, and Path are populated automatically from pctx. +// +// Authors may set Plugin, Phase, or Path on the argument explicitly to +// override the framework defaults — useful for test helpers or for a +// plugin synthesizing an invocation on behalf of a delegated sub-plugin. +// In normal plugin code, leave those fields zero. +// +// For the bare (Action, Reason) case, prefer the convenience wrappers +// (Allow / Skip / Observe / Modify) below — one line each. +func (c *Context) Record(inv Invocation) { + if inv.Plugin == "" { + inv.Plugin = c.currentPlugin + } + if inv.Phase == "" { + inv.Phase = c.currentPhase + } + if inv.Path == "" { + inv.Path = c.Path + } + c.appendInvocation(inv) +} + +// Allow records an Invocation with Action=allow and the given Reason. +// Convenience for gate plugins on the approved branch. +func (c *Context) Allow(reason string) { + c.Record(Invocation{Action: ActionAllow, Reason: reason}) +} + +// Skip records an Invocation with Action=skip. Convenience for plugins +// that ran but didn't act on this message (path bypass, no route match, +// parser skipping a non-matching body). +func (c *Context) Skip(reason string) { + c.Record(Invocation{Action: ActionSkip, Reason: reason}) +} + +// Observe records an Invocation with Action=observe. Convenience for +// parsers that successfully extracted diagnostic data without +// modifying the message. +func (c *Context) Observe(reason string) { + c.Record(Invocation{Action: ActionObserve, Reason: reason}) +} + +// Modify records an Invocation with Action=modify. Convenience for +// plugins that mutated the message (token-exchange replacing the +// Authorization header, a header-rewriter). +func (c *Context) Modify(reason string) { + c.Record(Invocation{Action: ActionModify, Reason: reason}) +} + +// DenyAndRecord records an Invocation with Action=deny AND returns a +// Reject Action. Bundles the two steps a gate plugin always does +// together on the deny path — emit the diagnostic record, then return +// the Action that changes control flow. +// +// code/message become the pipeline.Violation that the listener +// serializes to an HTTP response. Reason becomes the Invocation's +// machine-stable reason code. +// +// If the plugin has richer diagnostic data to attach to the Invocation +// (ExpectedIssuer, TokenScopes, etc.), use the two-step form: call +// pctx.Record(Invocation{...}) explicitly, then return pipeline.Deny. +func (c *Context) DenyAndRecord(reason, code, message string) Action { + c.Record(Invocation{Action: ActionDeny, Reason: reason}) + return Deny(code, message) +} + +// appendInvocation routes an Invocation to the right direction bucket +// based on pctx.Direction. Private — plugins call Record or the +// Allow/Skip/Observe/Modify helpers above. Not exported so external +// plugin authors discover the ergonomic API first and only drop to the +// full Invocation struct when they need diagnostic fields. +func (c *Context) appendInvocation(inv Invocation) { + if c.Extensions.Invocations == nil { + c.Extensions.Invocations = &Invocations{} + } + switch c.Direction { + case Inbound: + c.Extensions.Invocations.Inbound = append(c.Extensions.Invocations.Inbound, inv) + case Outbound: + c.Extensions.Invocations.Outbound = append(c.Extensions.Invocations.Outbound, inv) + } } // AgentIdentity carries the agent's own workload identity. diff --git a/authbridge/authlib/pipeline/extensions.go b/authbridge/authlib/pipeline/extensions.go index e48113158..5e82bff47 100644 --- a/authbridge/authlib/pipeline/extensions.go +++ b/authbridge/authlib/pipeline/extensions.go @@ -25,7 +25,7 @@ import "time" // The listener serializes matching entries into SessionEvent.Plugins // at record time — keyed by the plugin name (suffix stripped). A // new plugin can surface events to /v1/sessions without any -// authlib modification. See authlib/plugins/CONVENTIONS.md for the +// authlib modification. See authbridge/docs/plugin-reference.md for the // convention + promotion criteria for named-slot graduation. // // The suffix convention keeps the two intents unambiguous at write diff --git a/authbridge/authlib/pipeline/holder.go b/authbridge/authlib/pipeline/holder.go new file mode 100644 index 000000000..95639e926 --- /dev/null +++ b/authbridge/authlib/pipeline/holder.go @@ -0,0 +1,72 @@ +package pipeline + +import ( + "context" + "sync/atomic" +) + +// Holder is an atomic wrapper over *Pipeline. +// +// Listeners hold a *Holder and call through it on every request; a +// reloader Store()s a newly-built pipeline under the hood, and the +// next Load returns it. atomic.Pointer guarantees a Load-then-use +// sequence sees a fully-initialized pipeline — a request that started +// before a swap finishes against the original *Pipeline, and the +// reloader is responsible for not Stop()ing the old pipeline until a +// drain window expires. +// +// A Holder has exactly one long-lived value; its identity is the slot +// listeners reference, not the pipeline at any given moment. +type Holder struct { + p atomic.Pointer[Pipeline] +} + +// NewHolder wraps p in a Holder. The Pipeline must already be +// non-nil — Holder.Load will return nil if it's unset, and the +// delegating helpers below will panic. Callers build the first +// pipeline at startup just like before and hand it to NewHolder. +func NewHolder(p *Pipeline) *Holder { + h := &Holder{} + h.p.Store(p) + return h +} + +// Load returns the current pipeline. Safe for concurrent use. Returns +// nil only if no pipeline was ever stored (NewHolder requires non-nil, +// so this only happens if a caller zero-valued a Holder). +func (h *Holder) Load() *Pipeline { return h.p.Load() } + +// Store replaces the current pipeline. Subsequent Loads return the +// new pipeline; in-flight requests that already Loaded the old one +// continue to run against it. The caller owns the lifecycle of the +// replaced pipeline — read the return value of the previous Load and +// call its Stop(ctx) after the intended drain window. +func (h *Holder) Store(p *Pipeline) { h.p.Store(p) } + +// Run is equivalent to h.Load().Run(ctx, pctx). Offered as a +// one-liner so listeners can keep their Pipeline-era call sites +// unchanged when they migrate their field type from *Pipeline to +// *Holder. One atomic Load per call. +func (h *Holder) Run(ctx context.Context, pctx *Context) Action { + return h.p.Load().Run(ctx, pctx) +} + +// RunResponse is equivalent to h.Load().RunResponse(ctx, pctx). See Run. +func (h *Holder) RunResponse(ctx context.Context, pctx *Context) Action { + return h.p.Load().RunResponse(ctx, pctx) +} + +// NeedsBody is equivalent to h.Load().NeedsBody(). Hot path on listeners +// that decide whether to buffer the request/response body. +func (h *Holder) NeedsBody() bool { return h.p.Load().NeedsBody() } + +// Ready is equivalent to h.Load().Ready(). +func (h *Holder) Ready() bool { return h.p.Load().Ready() } + +// NotReadyPlugin is equivalent to h.Load().NotReadyPlugin(). Used by +// /readyz handlers to produce a helpful error body. +func (h *Holder) NotReadyPlugin() string { return h.p.Load().NotReadyPlugin() } + +// Plugins is equivalent to h.Load().Plugins(). Used by the session +// events API to surface pipeline composition. +func (h *Holder) Plugins() []Plugin { return h.p.Load().Plugins() } diff --git a/authbridge/authlib/pipeline/holder_test.go b/authbridge/authlib/pipeline/holder_test.go new file mode 100644 index 000000000..2c0e23219 --- /dev/null +++ b/authbridge/authlib/pipeline/holder_test.go @@ -0,0 +1,156 @@ +package pipeline + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" +) + +// holderTestPlugin is a no-op plugin used to build test pipelines. +// Implements all optional interfaces so Holder's delegating methods +// have something to delegate to. +type holderTestPlugin struct { + name string + needsBody bool + notReady bool + runCount atomic.Int64 + respCount atomic.Int64 +} + +func (p *holderTestPlugin) Name() string { return p.name } +func (p *holderTestPlugin) Capabilities() PluginCapabilities { + return PluginCapabilities{BodyAccess: p.needsBody} +} +func (p *holderTestPlugin) OnRequest(_ context.Context, _ *Context) Action { + p.runCount.Add(1) + return Action{Type: Continue} +} +func (p *holderTestPlugin) OnResponse(_ context.Context, _ *Context) Action { + p.respCount.Add(1) + return Action{Type: Continue} +} +func (p *holderTestPlugin) Ready() bool { return !p.notReady } + +func mustPipeline(t *testing.T, plugins ...Plugin) *Pipeline { + t.Helper() + p, err := New(plugins) + if err != nil { + t.Fatalf("pipeline.New: %v", err) + } + return p +} + +func TestHolder_LoadStore(t *testing.T) { + a := &holderTestPlugin{name: "a"} + b := &holderTestPlugin{name: "b"} + pa := mustPipeline(t, a) + pb := mustPipeline(t, b) + + h := NewHolder(pa) + if got := h.Load(); got != pa { + t.Fatalf("Load before Store: got %p, want %p", got, pa) + } + h.Store(pb) + if got := h.Load(); got != pb { + t.Fatalf("Load after Store: got %p, want %p", got, pb) + } +} + +// Delegated methods must match calling the same method on the +// underlying pipeline directly. Guards against a future refactor that +// mistypes a pass-through. +func TestHolder_DelegatesMatchUnderlying(t *testing.T) { + plugins := []Plugin{ + &holderTestPlugin{name: "a", needsBody: true}, + &holderTestPlugin{name: "b"}, + } + p := mustPipeline(t, plugins...) + h := NewHolder(p) + + if h.NeedsBody() != p.NeedsBody() { + t.Errorf("NeedsBody delegation mismatch") + } + if h.Ready() != p.Ready() { + t.Errorf("Ready delegation mismatch") + } + if h.NotReadyPlugin() != p.NotReadyPlugin() { + t.Errorf("NotReadyPlugin delegation mismatch") + } + if len(h.Plugins()) != len(p.Plugins()) { + t.Errorf("Plugins delegation mismatch: %d vs %d", len(h.Plugins()), len(p.Plugins())) + } + + // Simulate a plugin going not-ready; the Holder should reflect it. + plugins[0].(*holderTestPlugin).notReady = true + if h.NotReadyPlugin() != "a" { + t.Errorf("NotReadyPlugin after flag flip: got %q, want %q", h.NotReadyPlugin(), "a") + } +} + +// Run under concurrent Store: no races, no panics, every goroutine +// observes a non-nil pipeline. The pipeline returned from Load may +// change mid-race; we only assert memory safety under -race. +func TestHolder_ConcurrentRunAndStore(t *testing.T) { + pa := mustPipeline(t, &holderTestPlugin{name: "a"}) + pb := mustPipeline(t, &holderTestPlugin{name: "b"}) + pc := mustPipeline(t, &holderTestPlugin{name: "c"}) + pipelines := []*Pipeline{pa, pb, pc} + + h := NewHolder(pa) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var wg sync.WaitGroup + + // 10 runners hammer Run/RunResponse. We accept Reject only when ctx + // has been cancelled — pipeline.Run short-circuits cancelled contexts + // by returning a Deny action, which is semantically correct but not + // what this test is asserting. We care that Load->Run never NPEs or + // tears under concurrent Store. + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + pctx := &Context{Path: "/x", Direction: Inbound} + for ctx.Err() == nil { + action := h.Run(ctx, pctx) + if action.Type != Continue && ctx.Err() == nil { + t.Errorf("unexpected non-Continue from no-op pipeline: %v", action.Type) + } + action = h.RunResponse(ctx, pctx) + if action.Type != Continue && ctx.Err() == nil { + t.Errorf("unexpected non-Continue from no-op response pipeline: %v", action.Type) + } + } + }() + } + + // 1 swapper rotates through pipelines. + wg.Add(1) + go func() { + defer wg.Done() + i := 0 + for ctx.Err() == nil { + h.Store(pipelines[i%len(pipelines)]) + i++ + } + }() + + time.Sleep(200 * time.Millisecond) + cancel() + wg.Wait() +} + +// Holder must not accept a nil pipeline silently: Load returning nil +// would NPE in every delegating method. NewHolder takes a non-nil by +// contract; this asserts that contract is enforced in the only way +// the type system lets us — the panic surfaces immediately under -race +// rather than later at the first Run. +func TestHolder_ZeroValueLoadsNil(t *testing.T) { + var h Holder + if got := h.Load(); got != nil { + t.Fatalf("zero-value Holder Load: got %p, want nil", got) + } +} diff --git a/authbridge/authlib/pipeline/pipeline.go b/authbridge/authlib/pipeline/pipeline.go index f7e4ea1f4..51fe5c18d 100644 --- a/authbridge/authlib/pipeline/pipeline.go +++ b/authbridge/authlib/pipeline/pipeline.go @@ -59,13 +59,22 @@ func New(plugins []Plugin, opts ...Option) (*Pipeline, error) { // Run executes the request phase of the pipeline sequentially. // If any plugin returns Reject, the pipeline stops and returns that action // with Violation.PluginName populated. +// +// Before dispatching into each plugin, Run stamps pctx with the plugin's +// name and the current phase so the plugin's Record / Allow / Skip / +// Observe / Modify / DenyAndRecord helpers can fill Invocation.Plugin +// and Invocation.Phase automatically. The stamp is cleared after each +// plugin returns so a plugin that spawns a goroutine capturing pctx +// won't mis-attribute a late-arriving Record to itself. func (p *Pipeline) Run(ctx context.Context, pctx *Context) Action { for _, plugin := range p.plugins { if ctx.Err() != nil { slog.Info("pipeline: request cancelled", "plugin", plugin.Name()) return Deny("pipeline.cancelled", "request cancelled") } + pctx.SetCurrentPlugin(plugin.Name(), InvocationPhaseRequest) action := plugin.OnRequest(ctx, pctx) + pctx.ClearCurrentPlugin() if action.Type == Reject { stampPluginName(&action, plugin.Name()) logReject(plugin.Name(), action, "pipeline: plugin rejected request") @@ -78,13 +87,18 @@ func (p *Pipeline) Run(ctx context.Context, pctx *Context) Action { // RunResponse executes the response phase in reverse order. // The last plugin in the chain sees the response first. +// +// See Run for the pctx attribution stamping. Same pattern, phase set +// to InvocationPhaseResponse. func (p *Pipeline) RunResponse(ctx context.Context, pctx *Context) Action { for i := len(p.plugins) - 1; i >= 0; i-- { if ctx.Err() != nil { slog.Info("pipeline: response cancelled", "plugin", p.plugins[i].Name()) return Deny("pipeline.cancelled", "request cancelled") } + pctx.SetCurrentPlugin(p.plugins[i].Name(), InvocationPhaseResponse) action := p.plugins[i].OnResponse(ctx, pctx) + pctx.ClearCurrentPlugin() if action.Type == Reject { stampPluginName(&action, p.plugins[i].Name()) logReject(p.plugins[i].Name(), action, "pipeline: plugin rejected response") diff --git a/authbridge/authlib/pipeline/session.go b/authbridge/authlib/pipeline/session.go index c87a79114..1868a5e17 100644 --- a/authbridge/authlib/pipeline/session.go +++ b/authbridge/authlib/pipeline/session.go @@ -99,7 +99,7 @@ type SessionEvent struct { // consumers see the plugin name as the map key. Value is the plugin- // provided struct marshaled to JSON — opaque from the listener's // perspective. Consumers decode each key into their own type. See - // authlib/plugins/CONVENTIONS.md for the producer contract. + // authbridge/docs/plugin-reference.md for the producer contract. Plugins map[string]json.RawMessage // Identity snapshot at record time. Lets downstream plugins attribute an diff --git a/authbridge/authlib/plugins/README.md b/authbridge/authlib/plugins/README.md new file mode 100644 index 000000000..839079ada --- /dev/null +++ b/authbridge/authlib/plugins/README.md @@ -0,0 +1,28 @@ +# plugins + +Built-in plugins and the open plugin registry. Plugin authoring docs live under +[`authbridge/docs/`](../../docs/): + +- Tutorial: [`docs/plugin-tutorial.md`](../../docs/plugin-tutorial.md) +- Reference: [`docs/plugin-reference.md`](../../docs/plugin-reference.md) — config conventions, invocation contract, registration rules +- Framework architecture: [`docs/framework-architecture.md`](../../docs/framework-architecture.md) + +## Built-in plugins + +| Name | Purpose | +|---|---| +| `jwt-validation` | Inbound JWT signature / issuer / audience verification | +| `token-exchange` | Outbound RFC 8693 token exchange with per-host routes | +| `a2a-parser` | Parse Agent-to-Agent JSON-RPC traffic into `Extensions.A2A` | +| `mcp-parser` | Parse Model Context Protocol traffic into `Extensions.MCP` | +| `inference-parser` | Parse OpenAI-style / Ollama inference traffic into `Extensions.Inference` | + +## Registry + +Plugins self-register via `RegisterPlugin(name, factory)` from `init()`. +Third-party plugins can register from any Go module and are linked in via +side-effect import. See +[`docs/plugin-reference.md`](../../docs/plugin-reference.md#registering-a-plugin) +for the contract and +[`docs/plugin-tutorial.md`](../../docs/plugin-tutorial.md#step-6--out-of-tree-plugins) +for the walkthrough. diff --git a/authbridge/authlib/plugins/a2aparser.go b/authbridge/authlib/plugins/a2aparser.go index 979bbf176..9dd4db010 100644 --- a/authbridge/authlib/plugins/a2aparser.go +++ b/authbridge/authlib/plugins/a2aparser.go @@ -16,6 +16,10 @@ type A2AParser struct{} func NewA2AParser() *A2AParser { return &A2AParser{} } +func init() { + RegisterPlugin("a2a-parser", func() pipeline.Plugin { return NewA2AParser() }) +} + func (p *A2AParser) Name() string { return "a2a-parser" } func (p *A2AParser) Capabilities() pipeline.PluginCapabilities { @@ -80,13 +84,7 @@ func (p *A2AParser) OnRequest(_ context.Context, pctx *pipeline.Context) pipelin for i, part := range ext.Parts { slog.Debug("a2a-parser: part", "index", i, "kind", part.Kind, "content", truncate(part.Content, debugBodyMax)) } - appendInvocationInbound(pctx, pipeline.Invocation{ - Plugin: "a2a-parser", - Phase: pipeline.InvocationPhaseRequest, - Action: pipeline.ActionObserve, - Reason: "matched_" + rpc.Method, - Path: pctx.Path, - }) + pctx.Observe("matched_" + rpc.Method) return pipeline.Action{Type: pipeline.Continue} } @@ -125,13 +123,7 @@ func (p *A2AParser) OnResponse(_ context.Context, pctx *pipeline.Context) pipeli "artifactLen", len(pctx.Extensions.A2A.Artifact), "error", pctx.Extensions.A2A.ErrorMessage, ) - appendInvocationInbound(pctx, pipeline.Invocation{ - Plugin: "a2a-parser", - Phase: pipeline.InvocationPhaseResponse, - Action: pipeline.ActionObserve, - Reason: "matched_" + pctx.Extensions.A2A.Method + "_response", - Path: pctx.Path, - }) + pctx.Observe("matched_" + pctx.Extensions.A2A.Method + "_response") return pipeline.Action{Type: pipeline.Continue} } diff --git a/authbridge/authlib/plugins/inferenceparser.go b/authbridge/authlib/plugins/inferenceparser.go index cfe944cd2..270089257 100644 --- a/authbridge/authlib/plugins/inferenceparser.go +++ b/authbridge/authlib/plugins/inferenceparser.go @@ -16,6 +16,10 @@ type InferenceParser struct{} func NewInferenceParser() *InferenceParser { return &InferenceParser{} } +func init() { + RegisterPlugin("inference-parser", func() pipeline.Plugin { return NewInferenceParser() }) +} + func (p *InferenceParser) Name() string { return "inference-parser" } func (p *InferenceParser) Capabilities() pipeline.PluginCapabilities { @@ -81,13 +85,7 @@ func (p *InferenceParser) OnRequest(_ context.Context, pctx *pipeline.Context) p slog.Debug("inference-parser: message", "index", i, "role", m.Role, "content", truncate(m.Content, debugBodyMax)) } - appendInvocationOutbound(pctx, pipeline.Invocation{ - Plugin: "inference-parser", - Phase: pipeline.InvocationPhaseRequest, - Action: pipeline.ActionObserve, - Reason: "matched_" + ext.Model, - Path: pctx.Path, - }) + pctx.Observe("matched_" + ext.Model) return pipeline.Action{Type: pipeline.Continue} } @@ -115,13 +113,7 @@ func (p *InferenceParser) OnResponse(_ context.Context, pctx *pipeline.Context) "completionTokens", ext.CompletionTokens, ) slog.Debug("inference-parser: completion", "text", truncate(ext.Completion, debugBodyMax)) - appendInvocationOutbound(pctx, pipeline.Invocation{ - Plugin: "inference-parser", - Phase: pipeline.InvocationPhaseResponse, - Action: pipeline.ActionObserve, - Reason: "matched_" + ext.Model + "_response", - Path: pctx.Path, - }) + pctx.Observe("matched_" + ext.Model + "_response") return pipeline.Action{Type: pipeline.Continue} } diff --git a/authbridge/authlib/plugins/jwtvalidation.go b/authbridge/authlib/plugins/jwtvalidation.go index 0bedf6632..ee9cb9606 100644 --- a/authbridge/authlib/plugins/jwtvalidation.go +++ b/authbridge/authlib/plugins/jwtvalidation.go @@ -19,7 +19,7 @@ import ( ) // jwtValidationConfig is the plugin's local config schema. See -// authlib/plugins/CONVENTIONS.md for the decode → applyDefaults → +// authbridge/docs/plugin-reference.md for the decode → applyDefaults → // validate pattern. type jwtValidationConfig struct { // Issuer is the JWT `iss` claim expected on inbound tokens. @@ -160,6 +160,10 @@ type JWTValidation struct { // called before the pipeline accepts traffic. func NewJWTValidation() *JWTValidation { return &JWTValidation{} } +func init() { + RegisterPlugin("jwt-validation", func() pipeline.Plugin { return NewJWTValidation() }) +} + func (p *JWTValidation) Name() string { return "jwt-validation" } func (p *JWTValidation) Capabilities() pipeline.PluginCapabilities { @@ -297,13 +301,13 @@ func (p *JWTValidation) OnRequest(ctx context.Context, pctx *pipeline.Context) p // Surface the decision on pctx BEFORE returning so the listener's // reject path can record a SessionDenied event with diagnostic // context (why the token failed, what was expected). Never put - // the raw token here — session store has no auth. - appendInvocationInbound(pctx, pipeline.Invocation{ - Plugin: "jwt-validation", - Phase: pipeline.InvocationPhaseRequest, + // the raw token here — session store has no auth. The two-step + // form (Record + Deny) is used here because we attach the + // ExpectedIssuer / ExpectedAudience diagnostic fields that the + // one-liner DenyAndRecord doesn't accept. + pctx.Record(pipeline.Invocation{ Action: pipeline.ActionDeny, Reason: result.DenyReasonCode.String(), - Path: path, ExpectedIssuer: p.cfg.Issuer, ExpectedAudience: audience, }) @@ -324,13 +328,7 @@ func (p *JWTValidation) OnRequest(ctx context.Context, pctx *pipeline.Context) p // session stream — useful for debugging "why is this URL skipping // JWT?" without hunting through slog lines. if result.Claims == nil { - appendInvocationInbound(pctx, pipeline.Invocation{ - Plugin: "jwt-validation", - Phase: pipeline.InvocationPhaseRequest, - Action: pipeline.ActionSkip, - Reason: "path_bypass", - Path: path, - }) + pctx.Skip("path_bypass") return pipeline.Action{Type: pipeline.Continue} } @@ -338,12 +336,9 @@ func (p *JWTValidation) OnRequest(ctx context.Context, pctx *pipeline.Context) p // VERIFIED in the token — diverges from the top-level Identity // snapshot if later plugins re-annotate pctx.Claims. pctx.Claims = result.Claims - appendInvocationInbound(pctx, pipeline.Invocation{ - Plugin: "jwt-validation", - Phase: pipeline.InvocationPhaseRequest, + pctx.Record(pipeline.Invocation{ Action: pipeline.ActionAllow, Reason: auth.APPROVE_AUTHORIZED.String(), - Path: path, TokenSubject: result.Claims.Subject, TokenAudience: result.Claims.Audience, TokenScopes: result.Claims.Scopes, @@ -351,16 +346,6 @@ func (p *JWTValidation) OnRequest(ctx context.Context, pctx *pipeline.Context) p return pipeline.Action{Type: pipeline.Continue} } -// appendInvocationInbound lazy-creates pctx.Extensions.Invocations and -// appends one entry under Inbound. Symmetric with how a2a-parser -// initializes its extension slot in OnRequest. -func appendInvocationInbound(pctx *pipeline.Context, entry pipeline.Invocation) { - if pctx.Extensions.Invocations == nil { - pctx.Extensions.Invocations = &pipeline.Invocations{} - } - pctx.Extensions.Invocations.Inbound = append(pctx.Extensions.Invocations.Inbound, entry) -} - func (p *JWTValidation) OnResponse(_ context.Context, _ *pipeline.Context) pipeline.Action { return pipeline.Action{Type: pipeline.Continue} } diff --git a/authbridge/authlib/plugins/mcpparser.go b/authbridge/authlib/plugins/mcpparser.go index 77dc2fab8..e41ccc644 100644 --- a/authbridge/authlib/plugins/mcpparser.go +++ b/authbridge/authlib/plugins/mcpparser.go @@ -16,6 +16,10 @@ type MCPParser struct{} func NewMCPParser() *MCPParser { return &MCPParser{} } +func init() { + RegisterPlugin("mcp-parser", func() pipeline.Plugin { return NewMCPParser() }) +} + func (p *MCPParser) Name() string { return "mcp-parser" } func (p *MCPParser) Capabilities() pipeline.PluginCapabilities { @@ -59,13 +63,7 @@ func (p *MCPParser) OnRequest(_ context.Context, pctx *pipeline.Context) pipelin slog.Info("mcp-parser: request", "method", rpc.Method) slog.Debug("mcp-parser: payload", "method", rpc.Method, "body", truncate(string(pctx.Body), debugBodyMax)) - appendInvocationOutbound(pctx, pipeline.Invocation{ - Plugin: "mcp-parser", - Phase: pipeline.InvocationPhaseRequest, - Action: pipeline.ActionObserve, - Reason: "matched_" + rpc.Method, - Path: pctx.Path, - }) + pctx.Observe("matched_" + rpc.Method) return pipeline.Action{Type: pipeline.Continue} } @@ -82,13 +80,7 @@ func (p *MCPParser) OnResponse(_ context.Context, pctx *pipeline.Context) pipeli rpc, ok := parseMCPResponse(pctx.ResponseBody) if !ok { slog.Debug("mcp-parser: response is not valid JSON-RPC or SSE", "bodyLen", len(pctx.ResponseBody)) - appendInvocationOutbound(pctx, pipeline.Invocation{ - Plugin: "mcp-parser", - Phase: pipeline.InvocationPhaseResponse, - Action: pipeline.ActionSkip, - Reason: "unparseable_response", - Path: pctx.Path, - }) + pctx.Skip("unparseable_response") return pipeline.Action{Type: pipeline.Continue} } @@ -99,13 +91,7 @@ func (p *MCPParser) OnResponse(_ context.Context, pctx *pipeline.Context) pipeli Data: rpc.Error.Data, } slog.Info("mcp-parser: response error", "method", pctx.Extensions.MCP.Method, "code", rpc.Error.Code, "message", rpc.Error.Message) - appendInvocationOutbound(pctx, pipeline.Invocation{ - Plugin: "mcp-parser", - Phase: pipeline.InvocationPhaseResponse, - Action: pipeline.ActionObserve, - Reason: "response_error", - Path: pctx.Path, - }) + pctx.Observe("response_error") return pipeline.Action{Type: pipeline.Continue} } @@ -115,13 +101,7 @@ func (p *MCPParser) OnResponse(_ context.Context, pctx *pipeline.Context) pipeli slog.Debug("mcp-parser: response detail", "method", pctx.Extensions.MCP.Method, "body", truncate(string(pctx.ResponseBody), debugBodyMax)) } - appendInvocationOutbound(pctx, pipeline.Invocation{ - Plugin: "mcp-parser", - Phase: pipeline.InvocationPhaseResponse, - Action: pipeline.ActionObserve, - Reason: "matched_" + pctx.Extensions.MCP.Method + "_response", - Path: pctx.Path, - }) + pctx.Observe("matched_" + pctx.Extensions.MCP.Method + "_response") return pipeline.Action{Type: pipeline.Continue} } diff --git a/authbridge/authlib/plugins/plugins_test.go b/authbridge/authlib/plugins/plugins_test.go index 4aa7bf2d5..0f29efd6c 100644 --- a/authbridge/authlib/plugins/plugins_test.go +++ b/authbridge/authlib/plugins/plugins_test.go @@ -17,6 +17,26 @@ import ( "github.com/kagenti/kagenti-extensions/authbridge/authlib/validation" ) +// invokeOnRequest mirrors what Pipeline.Run does around each plugin +// dispatch: set the current-plugin / current-phase attribution fields +// on pctx so pctx.Record / Allow / Skip / Observe / Modify fill in +// Plugin and Phase correctly. Tests that call plugin.OnRequest directly +// (bypassing Pipeline.Run) need this wrapper to exercise the same code +// path as production. Without it, Invocations would land with empty +// Plugin and Phase fields. +func invokeOnRequest(p pipeline.Plugin, pctx *pipeline.Context) pipeline.Action { + pctx.SetCurrentPlugin(p.Name(), pipeline.InvocationPhaseRequest) + defer pctx.ClearCurrentPlugin() + return p.OnRequest(context.Background(), pctx) +} + +// invokeOnResponse is the response-phase twin of invokeOnRequest. +func invokeOnResponse(p pipeline.Plugin, pctx *pipeline.Context) pipeline.Action { + pctx.SetCurrentPlugin(p.Name(), pipeline.InvocationPhaseResponse) + defer pctx.ClearCurrentPlugin() + return p.OnResponse(context.Background(), pctx) +} + // TestAuthbridgeCombinedYAML_Loads asserts that the in-repo default // config consumed by the combined sidecar image // (authbridge/authproxy/authbridge-combined.yaml) parses, env-expands, @@ -305,7 +325,7 @@ func TestJWTValidation_Ready_PerHostAlwaysReady(t *testing.T) { func TestJWTValidation_OnRequest_NotConfigured(t *testing.T) { p := NewJWTValidation() - action := p.OnRequest(context.Background(), &pipeline.Context{Headers: http.Header{}}) + action := invokeOnRequest(p, &pipeline.Context{Headers: http.Header{}}) if action.Type != pipeline.Reject { t.Errorf("got %v, want Reject for unconfigured plugin", action.Type) } @@ -352,7 +372,7 @@ func TestJWTValidation_OnRequest_PopulatesAuth_Bypass(t *testing.T) { p := newTestJWTValidation(t, "http://issuer", inner) pctx := &pipeline.Context{Headers: http.Header{}, Path: "/healthz"} - action := p.OnRequest(context.Background(), pctx) + action := invokeOnRequest(p, pctx) if action.Type != pipeline.Continue { t.Fatalf("bypass should Continue, got %v", action.Type) } @@ -376,7 +396,7 @@ func TestJWTValidation_OnRequest_PopulatesAuth_Deny_NoHeader(t *testing.T) { p := newTestJWTValidation(t, "http://issuer.example", inner) pctx := &pipeline.Context{Headers: http.Header{}, Path: "/api/call"} - action := p.OnRequest(context.Background(), pctx) + action := invokeOnRequest(p, pctx) if action.Type != pipeline.Reject { t.Fatalf("expected Reject on missing auth header, got %v", action.Type) } @@ -413,7 +433,7 @@ func TestJWTValidation_OnRequest_PopulatesAuth_Allow(t *testing.T) { pctx := &pipeline.Context{Headers: http.Header{}, Path: "/api/call"} pctx.Headers.Set("Authorization", "Bearer tok") - action := p.OnRequest(context.Background(), pctx) + action := invokeOnRequest(p, pctx) if action.Type != pipeline.Continue { t.Fatalf("expected Continue, got %v (violation=%+v)", action.Type, action.Violation) } @@ -628,7 +648,7 @@ func TestTokenExchange_Passthrough(t *testing.T) { Host: "some-host", Headers: http.Header{"Authorization": []string{"Bearer user-token"}}, } - action := p.OnRequest(context.Background(), pctx) + action := invokeOnRequest(p, pctx) if action.Type != pipeline.Continue { t.Fatalf("got %v, want Continue", action.Type) } @@ -680,7 +700,7 @@ func TestTokenExchange_ExchangeSuccess(t *testing.T) { Host: "target-svc", Headers: http.Header{"Authorization": []string{"Bearer user-token"}}, } - action := p.OnRequest(context.Background(), pctx) + action := invokeOnRequest(p, pctx) if action.Type != pipeline.Continue { t.Fatalf("got %v, want Continue", action.Type) } @@ -727,7 +747,7 @@ func TestTokenExchange_ExchangeFailure(t *testing.T) { Host: "target-svc", Headers: http.Header{"Authorization": []string{"Bearer user-token"}}, } - action := p.OnRequest(context.Background(), pctx) + action := invokeOnRequest(p, pctx) if action.Type != pipeline.Reject { t.Fatalf("got %v, want Reject", action.Type) } @@ -766,7 +786,7 @@ func TestTokenExchange_NoToken_Deny(t *testing.T) { Host: "target-svc", Headers: http.Header{}, } - action := p.OnRequest(context.Background(), pctx) + action := invokeOnRequest(p, pctx) if action.Type != pipeline.Reject { t.Fatalf("got %v, want Reject", action.Type) } diff --git a/authbridge/authlib/plugins/registry.go b/authbridge/authlib/plugins/registry.go index a79059c31..88b0664de 100644 --- a/authbridge/authlib/plugins/registry.go +++ b/authbridge/authlib/plugins/registry.go @@ -2,6 +2,8 @@ package plugins import ( "fmt" + "sort" + "sync" "github.com/kagenti/kagenti-extensions/authbridge/authlib/config" "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" @@ -14,12 +16,76 @@ import ( // local config inside Configure. type PluginFactory func() pipeline.Plugin -var registry = map[string]PluginFactory{ - "jwt-validation": func() pipeline.Plugin { return NewJWTValidation() }, - "token-exchange": func() pipeline.Plugin { return NewTokenExchange() }, - "mcp-parser": func() pipeline.Plugin { return NewMCPParser() }, - "a2a-parser": func() pipeline.Plugin { return NewA2AParser() }, - "inference-parser": func() pipeline.Plugin { return NewInferenceParser() }, +// registry is the dynamic plugin table. Populated by RegisterPlugin, +// typically from each plugin package's init() function. Guarded by a +// mutex because init() order across packages isn't guaranteed to be +// serial under every Go build mode, and tests use UnregisterPlugin +// concurrently with t.Parallel. +var ( + registryMu sync.RWMutex + registry = map[string]PluginFactory{} +) + +// RegisterPlugin adds a plugin factory under name. Intended to be +// called from package init() functions of plugin implementations: +// +// func init() { +// plugins.RegisterPlugin("rate-limiter", func() pipeline.Plugin { +// return &RateLimiter{} +// }) +// } +// +// This is the stdlib pattern (database/sql.Register, image codec +// registration, log/slog handler registration): plugins live in their +// own package and advertise themselves by side-effect import: +// +// import _ "github.com/acme/kagenti-rate-limiter/ratelimit" +// +// Double-registration under the same name panics. Silent last-write- +// wins would let a version mismatch or deployment bug poison the +// registry in ways that only surface as mysterious runtime behaviour; +// failing loud at process start is strictly safer. +// +// Empty name or nil factory also panics — both are programmer errors, +// not recoverable conditions. +func RegisterPlugin(name string, factory PluginFactory) { + if name == "" { + panic("plugins: RegisterPlugin called with empty name") + } + if factory == nil { + panic(fmt.Sprintf("plugins: RegisterPlugin(%q) factory is nil", name)) + } + registryMu.Lock() + defer registryMu.Unlock() + if _, exists := registry[name]; exists { + panic(fmt.Sprintf("plugins: %q already registered", name)) + } + registry[name] = factory +} + +// RegisteredPlugins returns the names of every registered plugin in +// sorted order. Intended for diagnostic surfaces (/config, CLI --help, +// Build's "unknown plugin" error message) and for tests that assert a +// plugin is visible to the builder. +func RegisteredPlugins() []string { + registryMu.RLock() + defer registryMu.RUnlock() + names := make([]string, 0, len(registry)) + for n := range registry { + names = append(names, n) + } + sort.Strings(names) + return names +} + +// factoryFor looks up a factory by name. Internal to the package. +// Callers under Build use this to resolve config entries into plugin +// instances. +func factoryFor(name string) (PluginFactory, bool) { + registryMu.RLock() + defer registryMu.RUnlock() + f, ok := registry[name] + return f, ok } // Build constructs a pipeline from an ordered list of plugin entries. @@ -29,13 +95,14 @@ var registry = map[string]PluginFactory{ // stale or misplaced config blocks fail at startup instead of being // silently ignored. // -// Unknown plugin names fail fast. +// Unknown plugin names fail fast with an error that lists every +// currently-registered plugin — typo-catching diagnostic. func Build(entries []config.PluginEntry, opts ...pipeline.Option) (*pipeline.Pipeline, error) { ps := make([]pipeline.Plugin, 0, len(entries)) for _, e := range entries { - factory, ok := registry[e.Name] + factory, ok := factoryFor(e.Name) if !ok { - return nil, fmt.Errorf("unknown plugin %q", e.Name) + return nil, fmt.Errorf("unknown plugin %q (registered: %v)", e.Name, RegisteredPlugins()) } p := factory() if c, ok := p.(pipeline.Configurable); ok { diff --git a/authbridge/authlib/plugins/registry_test.go b/authbridge/authlib/plugins/registry_test.go new file mode 100644 index 000000000..7d2049b1c --- /dev/null +++ b/authbridge/authlib/plugins/registry_test.go @@ -0,0 +1,122 @@ +package plugins + +import ( + "testing" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/config" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" +) + +// TestBuiltinsRegistered verifies every in-tree plugin is discoverable +// through the new registry — the list is the public contract that +// operator YAML depends on, so a regression here breaks deployments. +func TestBuiltinsRegistered(t *testing.T) { + want := map[string]bool{ + "jwt-validation": true, + "token-exchange": true, + "a2a-parser": true, + "mcp-parser": true, + "inference-parser": true, + } + got := RegisteredPlugins() + gotSet := make(map[string]bool, len(got)) + for _, n := range got { + gotSet[n] = true + } + for name := range want { + if !gotSet[name] { + t.Errorf("built-in plugin %q missing from registry; got: %v", name, got) + } + } +} + +// TestRegisterPlugin_DoubleRegistration_Panics locks the strict-fail +// policy. Silent last-write-wins would let a deployment with two +// incompatible copies of the same plugin corrupt the pipeline +// composition; panic on registration catches it at process start. +func TestRegisterPlugin_DoubleRegistration_Panics(t *testing.T) { + name := "test-double-register" + RegisterPlugin(name, func() pipeline.Plugin { return nil }) + t.Cleanup(func() { UnregisterPlugin(name) }) + + defer func() { + if r := recover(); r == nil { + t.Errorf("expected panic on double-registration") + } + }() + RegisterPlugin(name, func() pipeline.Plugin { return nil }) +} + +func TestRegisterPlugin_EmptyName_Panics(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Errorf("expected panic on empty name") + } + }() + RegisterPlugin("", func() pipeline.Plugin { return nil }) +} + +func TestRegisterPlugin_NilFactory_Panics(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Errorf("expected panic on nil factory") + } + }() + RegisterPlugin("test-nil-factory", nil) +} + +// TestUnregisterPlugin verifies the test-isolation helper. After +// registering + unregistering, the name is absent from RegisteredPlugins +// and Build rejects it as unknown. +func TestUnregisterPlugin(t *testing.T) { + name := "test-unregister" + RegisterPlugin(name, func() pipeline.Plugin { return nil }) + if !contains(RegisteredPlugins(), name) { + t.Fatalf("plugin not registered after RegisterPlugin") + } + if !UnregisterPlugin(name) { + t.Errorf("UnregisterPlugin returned false for a registered name") + } + if contains(RegisteredPlugins(), name) { + t.Errorf("plugin still in registry after UnregisterPlugin") + } + // Second unregister should be a no-op (returns false). + if UnregisterPlugin(name) { + t.Errorf("UnregisterPlugin returned true for an unregistered name") + } +} + +// TestBuild_UnknownPlugin_ListsRegistered verifies the "unknown plugin" +// error includes the list of registered names so operators get a +// typo-catching diagnostic instead of a generic not-found. +func TestBuild_UnknownPlugin_ListsRegistered(t *testing.T) { + _, err := Build([]config.PluginEntry{{Name: "not-a-real-plugin"}}) + if err == nil { + t.Fatalf("expected error for unknown plugin") + } + msg := err.Error() + if !containsSubstring(msg, "not-a-real-plugin") { + t.Errorf("error should name the unknown plugin: %q", msg) + } + if !containsSubstring(msg, "jwt-validation") { + t.Errorf("error should list registered plugins (for typo diagnostics): %q", msg) + } +} + +func contains(haystack []string, needle string) bool { + for _, s := range haystack { + if s == needle { + return true + } + } + return false +} + +func containsSubstring(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/authbridge/authlib/plugins/registry_testing.go b/authbridge/authlib/plugins/registry_testing.go new file mode 100644 index 000000000..68df9f3a2 --- /dev/null +++ b/authbridge/authlib/plugins/registry_testing.go @@ -0,0 +1,30 @@ +package plugins + +// This file is intentionally NOT named with a _test.go suffix so that +// UnregisterPlugin is importable by tests in OTHER packages (e.g., +// cmd/authbridge/listener tests that want to register a fake plugin). +// It IS, however, clearly-named and documented as a test affordance — +// callers must not use it in production code paths. + +// UnregisterPlugin removes a plugin factory from the registry. Intended +// for test isolation: a test registers a fake plugin, runs, and uses +// t.Cleanup to unregister so parallel tests aren't poisoned by the +// leftover entry. +// +// Do not call from production code. The registry is intended to be +// written exactly once per plugin per process, at init() time; runtime +// deregistration has no valid use case in a running authbridge binary +// and would make the /config endpoint lie about pipeline composition. +// +// Returns true when the name was registered (and is now removed), false +// when it wasn't. Callers ignoring the return value are common and +// correct — Cleanup doesn't care. +func UnregisterPlugin(name string) bool { + registryMu.Lock() + defer registryMu.Unlock() + if _, ok := registry[name]; !ok { + return false + } + delete(registry, name) + return true +} diff --git a/authbridge/authlib/plugins/tokenexchange.go b/authbridge/authlib/plugins/tokenexchange.go index 0dad916ca..57e7c4dcf 100644 --- a/authbridge/authlib/plugins/tokenexchange.go +++ b/authbridge/authlib/plugins/tokenexchange.go @@ -21,7 +21,7 @@ import ( ) // tokenExchangeConfig is the plugin's local config schema. See -// authlib/plugins/CONVENTIONS.md for the pattern. +// authbridge/docs/plugin-reference.md for the pattern. type tokenExchangeConfig struct { // TokenURL is the OAuth token endpoint. Explicit value wins; else // derived from KeycloakURL + KeycloakRealm using Keycloak's @@ -203,6 +203,10 @@ type TokenExchange struct { // NewTokenExchange constructs an unconfigured plugin. func NewTokenExchange() *TokenExchange { return &TokenExchange{} } +func init() { + RegisterPlugin("token-exchange", func() pipeline.Plugin { return NewTokenExchange() }) +} + func (p *TokenExchange) Name() string { return "token-exchange" } func (p *TokenExchange) Capabilities() pipeline.PluginCapabilities { @@ -472,9 +476,7 @@ func (p *TokenExchange) OnRequest(ctx context.Context, pctx *pipeline.Context) p // either tighten routes or filter on action=passthrough in abctl. switch result.Action { case auth.ActionDeny: - appendInvocationOutbound(pctx, pipeline.Invocation{ - Plugin: "token-exchange", - Phase: pipeline.InvocationPhaseRequest, + pctx.Record(pipeline.Invocation{ Action: pipeline.ActionDeny, Reason: result.DenyReasonCode.String(), RouteMatched: result.RouteMatched, @@ -497,9 +499,7 @@ func (p *TokenExchange) OnRequest(ctx context.Context, pctx *pipeline.Context) p if result.CacheHit { reason = "cache_hit" } - appendInvocationOutbound(pctx, pipeline.Invocation{ - Plugin: "token-exchange", - Phase: pipeline.InvocationPhaseRequest, + pctx.Record(pipeline.Invocation{ Action: pipeline.ActionModify, Reason: reason, RouteMatched: true, @@ -517,9 +517,7 @@ func (p *TokenExchange) OnRequest(ctx context.Context, pctx *pipeline.Context) p if result.RouteMatched { reason = "route_passthrough" } - appendInvocationOutbound(pctx, pipeline.Invocation{ - Plugin: "token-exchange", - Phase: pipeline.InvocationPhaseRequest, + pctx.Record(pipeline.Invocation{ Action: pipeline.ActionSkip, Reason: reason, RouteMatched: result.RouteMatched, @@ -529,16 +527,6 @@ func (p *TokenExchange) OnRequest(ctx context.Context, pctx *pipeline.Context) p return pipeline.Action{Type: pipeline.Continue} } -// appendInvocationOutbound lazy-creates pctx.Extensions.Invocations and -// appends one entry under Outbound. Symmetric with appendInvocationInbound -// in jwtvalidation.go. -func appendInvocationOutbound(pctx *pipeline.Context, entry pipeline.Invocation) { - if pctx.Extensions.Invocations == nil { - pctx.Extensions.Invocations = &pipeline.Invocations{} - } - pctx.Extensions.Invocations.Outbound = append(pctx.Extensions.Invocations.Outbound, entry) -} - // splitScopes turns a space-separated scope string into []string. Returns // nil for the empty string so the JSON omitempty tag drops the field // entirely rather than emitting "[]". diff --git a/authbridge/authlib/reloader/reloader.go b/authbridge/authlib/reloader/reloader.go new file mode 100644 index 000000000..88befdc15 --- /dev/null +++ b/authbridge/authlib/reloader/reloader.go @@ -0,0 +1,339 @@ +// Package reloader watches a config file and atomically swaps the +// inbound / outbound plugin pipelines when the file changes. +// +// The watcher targets the *directory* containing the config file, +// not the file itself, because Kubernetes ConfigMap mounts use +// symlink swap (..data → ..). A direct file watch misses +// the symlink retarget. Directory watch + basename filter sees +// plain writes, atomic renames, and symlink swaps uniformly. +// +// On a detected change the reloader debounces, hashes the new +// content, validates it against the last-active config (refusing +// changes to unreloadable fields like mode / listener addresses), +// builds fresh pipelines via the caller-supplied PipelineBuilder, +// runs their Start hooks under a bounded context, and Stores them +// into the Holders. The old pipelines keep running until a drain +// window expires, then Stop is invoked in a background goroutine. +// +// Validation failure at any stage leaves the current pipelines +// untouched and records the error in Status so operators can see +// the cause via the /reload/status endpoint. +package reloader + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "log/slog" + "os" + "path/filepath" + "sync/atomic" + "time" + + "github.com/fsnotify/fsnotify" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/config" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" +) + +// PipelineBuilder loads the config file, validates it, and returns the +// built (but not-yet-Started) pipelines plus the parsed Config. The +// reloader invokes Start on the returned pipelines; on any Start failure +// it calls Stop to unwind. +// +// Implementations should mirror main.go's startup sequence exactly: +// config.Load → mode override → config.ApplyPreset → config.Validate → +// plugins.Build. Returning distinct errors for each stage lets the +// reloader surface a precise message on /reload/status. +type PipelineBuilder func() (inbound, outbound *pipeline.Pipeline, cfg *config.Config, err error) + +// Option configures a Reloader at construction time. +type Option func(*Reloader) + +// WithDrainWindow overrides the delay before old pipelines are Stopped +// after a successful swap. Default 30s. Tests pass 0 to avoid sleeping. +func WithDrainWindow(d time.Duration) Option { return func(r *Reloader) { r.drainWindow = d } } + +// WithDebounce overrides the debounce interval used to coalesce rapid +// fsnotify events (e.g., the REMOVE+CREATE+CHMOD sequence that fires +// on a k8s atomic symlink swap). Default 250ms. Tests pass a smaller +// value to keep suites fast. +func WithDebounce(d time.Duration) Option { return func(r *Reloader) { r.debounce = d } } + +// WithStartTimeout overrides the bound applied to the new pipelines' +// Start context. Default 60s (same as main.go startup). +func WithStartTimeout(d time.Duration) Option { return func(r *Reloader) { r.startTimeout = d } } + +// Reloader owns the fsnotify watcher and the reload orchestration. +// Safe for concurrent use — all internal state transitions happen +// inside the single watch goroutine or are guarded by atomic.Pointer. +type Reloader struct { + configPath string + inbound *pipeline.Holder + outbound *pipeline.Holder + build PipelineBuilder + + drainWindow time.Duration + debounce time.Duration + startTimeout time.Duration + + status atomic.Pointer[Status] + activeCfg atomic.Pointer[config.Config] + + // serialized by the watch goroutine + lastHash string +} + +// New constructs a Reloader. initialCfg must be the config the caller +// already loaded and built pipelines from — the reloader uses it as +// the baseline for detecting unreloadable-field changes. +func New(configPath string, inbound, outbound *pipeline.Holder, build PipelineBuilder, initialCfg *config.Config, opts ...Option) *Reloader { + r := &Reloader{ + configPath: configPath, + inbound: inbound, + outbound: outbound, + build: build, + drainWindow: 30 * time.Second, + debounce: 250 * time.Millisecond, + startTimeout: 60 * time.Second, + } + for _, opt := range opts { + opt(r) + } + r.activeCfg.Store(initialCfg) + + // Seed the hash with the bytes main.go just loaded, so a spurious + // fsnotify event that fires immediately after startup (e.g., the + // kubelet touching the mount) doesn't trigger a redundant rebuild. + if data, err := os.ReadFile(configPath); err == nil { + sum := sha256.Sum256(data) + r.lastHash = hex.EncodeToString(sum[:]) + } + + init := Status{LastAttempt: time.Time{}, LastSuccess: time.Now(), ActiveConfigSHA256: r.lastHash} + r.status.Store(&init) + return r +} + +// Start arms the watcher. Blocks until the watcher is ready or fails +// to arm (invalid directory, fsnotify init error). Returns nil on +// success; the watch goroutine then runs until ctx is cancelled. +// +// Safe to call at most once. A Reloader whose Start returned an error +// is not usable — construct a new one. +func (r *Reloader) Start(ctx context.Context) error { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return fmt.Errorf("fsnotify.NewWatcher: %w", err) + } + + dir := filepath.Dir(r.configPath) + if err := watcher.Add(dir); err != nil { + watcher.Close() + return fmt.Errorf("fsnotify.Add(%s): %w", dir, err) + } + + go r.watchLoop(ctx, watcher) + slog.Info("reloader: watching for config changes", + "path", r.configPath, "dir", dir, "drainWindow", r.drainWindow) + return nil +} + +// Status returns a snapshot of the most recent reload outcome. +func (r *Reloader) Status() Status { return *r.status.Load() } + +// ConfigProvider returns a closure the StatServer can call to render +// /config against the currently-active (post-swap) configuration. +func (r *Reloader) ConfigProvider() func() *config.Config { + return func() *config.Config { return r.activeCfg.Load() } +} + +// watchLoop drains fsnotify events, debounces them, and dispatches +// each debounced burst to reloadOnce. Exits when ctx is cancelled or +// the watcher channel closes. +func (r *Reloader) watchLoop(ctx context.Context, watcher *fsnotify.Watcher) { + defer watcher.Close() + + base := filepath.Base(r.configPath) + var timer *time.Timer + defer func() { + if timer != nil { + timer.Stop() + } + }() + + trigger := make(chan struct{}, 1) + + for { + select { + case <-ctx.Done(): + return + + case err, ok := <-watcher.Errors: + if !ok { + return + } + slog.Warn("reloader: fsnotify error", "error", err) + + case ev, ok := <-watcher.Events: + if !ok { + return + } + // Only care about events naming our file (or its ConfigMap + // alias directory ..data that symlink-swaps to it). The + // underlying timestamped dirs (..2026_05_08_xx) produce + // events too, but we catch the swap via the ..data rewrite. + if filepath.Base(ev.Name) != base && filepath.Base(ev.Name) != "..data" { + continue + } + slog.Debug("reloader: fs event", "name", ev.Name, "op", ev.Op.String()) + + // Debounce: reset the timer on every event. Fire once when + // the burst quiesces for `debounce`. + if timer == nil { + timer = time.AfterFunc(r.debounce, func() { + select { + case trigger <- struct{}{}: + default: + } + }) + } else { + timer.Reset(r.debounce) + } + + case <-trigger: + r.reloadOnce(ctx) + } + } +} + +// reloadOnce runs one reload attempt from start to finish. Any error +// leaves the currently-active pipelines untouched and is reported via +// Status. Only the debounced timer fires this, so two reloadOnce calls +// cannot overlap. +func (r *Reloader) reloadOnce(parent context.Context) { + attempt := time.Now() + + // Content-hash dedup: ignore mtime-only changes (e.g., a touch). + data, err := os.ReadFile(r.configPath) + if err != nil { + r.recordFailure(attempt, fmt.Errorf("read: %w", err)) + return + } + sum := sha256.Sum256(data) + hash := hex.EncodeToString(sum[:]) + if hash == r.lastHash { + slog.Debug("reloader: content unchanged, skipping reload", "sha256", hash[:12]) + return + } + + // Build + validate a new pair of pipelines. build() does Load + + // ApplyPreset + Validate + plugins.Build in the same order main.go + // used, so behavior is identical to process startup. + newIn, newOut, newCfg, err := r.build() + if err != nil { + r.recordFailure(attempt, fmt.Errorf("build: %w", err)) + return + } + + // Refuse changes to unreloadable fields. Listener addresses and + // mode bind to sockets that can't be rebound under the running + // gRPC/HTTP servers; operator needs a pod restart for those. + if err := validateReloadable(r.activeCfg.Load(), newCfg); err != nil { + r.recordFailure(attempt, err) + return + } + + // Start the new pipelines. On failure, Stop any partial state so + // background goroutines from the aborted build don't leak. + startCtx, cancel := context.WithTimeout(parent, r.startTimeout) + defer cancel() + if err := newIn.Start(startCtx); err != nil { + r.recordFailure(attempt, fmt.Errorf("inbound Start: %w", err)) + return + } + if err := newOut.Start(startCtx); err != nil { + // Inbound started cleanly; unwind it before bailing. + unwindCtx, unwindCancel := context.WithTimeout(context.Background(), 15*time.Second) + defer unwindCancel() + newIn.Stop(unwindCtx) + r.recordFailure(attempt, fmt.Errorf("outbound Start: %w", err)) + return + } + + // Commit: swap Holders and update active-config snapshot. From + // here on, requests drain onto the new pipelines; old in-flight + // requests keep their existing *Pipeline reference. + oldIn := r.inbound.Load() + oldOut := r.outbound.Load() + r.inbound.Store(newIn) + r.outbound.Store(newOut) + r.activeCfg.Store(newCfg) + r.lastHash = hash + + slog.Info("reloader: pipelines swapped", + "sha256", hash[:12], + "drainWindow", r.drainWindow) + + // Drain old pipelines in the background. Using context.Background + // deliberately: parent may cancel during shutdown, and we still + // want a bounded Stop on the old plugins so they flush cleanly. + go func() { + if r.drainWindow > 0 { + time.Sleep(r.drainWindow) + } + stopCtx, stopCancel := context.WithTimeout(context.Background(), 15*time.Second) + defer stopCancel() + if oldIn != nil { + oldIn.Stop(stopCtx) + } + if oldOut != nil { + oldOut.Stop(stopCtx) + } + slog.Info("reloader: drained old pipelines", "sha256", hash[:12]) + }() + + r.recordSuccess(attempt, hash) +} + +// validateReloadable returns an error when a newly-loaded config +// differs from the active one on a field that cannot be reloaded +// under the running listeners. +func validateReloadable(active, next *config.Config) error { + if active == nil { + return nil // first load + } + var diffs []string + if active.Mode != next.Mode { + diffs = append(diffs, fmt.Sprintf("mode (%s→%s)", active.Mode, next.Mode)) + } + if active.Listener != next.Listener { + diffs = append(diffs, "listener.*") + } + if len(diffs) > 0 { + return fmt.Errorf("unreloadable field changed, pod restart required: %v", diffs) + } + return nil +} + +func (r *Reloader) recordFailure(attempt time.Time, err error) { + slog.Warn("reloader: reload failed", "error", err) + cur := r.status.Load() + next := *cur + next.LastAttempt = attempt + next.LastError = err.Error() + next.ReloadsFailed++ + r.status.Store(&next) +} + +func (r *Reloader) recordSuccess(attempt time.Time, hash string) { + cur := r.status.Load() + next := *cur + next.LastAttempt = attempt + next.LastSuccess = attempt + next.LastError = "" + next.ReloadsOK++ + next.ActiveConfigSHA256 = hash + r.status.Store(&next) +} diff --git a/authbridge/authlib/reloader/reloader_test.go b/authbridge/authlib/reloader/reloader_test.go new file mode 100644 index 000000000..6c509e50d --- /dev/null +++ b/authbridge/authlib/reloader/reloader_test.go @@ -0,0 +1,277 @@ +package reloader + +import ( + "context" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/config" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" +) + +// buildFn captures the caller-supplied pipeline results so tests can +// steer each reload attempt's outcome (success with a given plugin +// list, build-time failure, Start-time failure). +type fakeBuilder struct { + // next is the result returned by the next build() call. Tests + // replace this before writing to the config file. The PipelineBuilder + // closure returned by (*fakeBuilder).build captures `b` and reads + // `next` at call time. + next atomic.Value // holds builderResult + cfg atomic.Value // holds *config.Config +} + +type builderResult struct { + inbound *pipeline.Pipeline + outbound *pipeline.Pipeline + cfg *config.Config + err error +} + +func (b *fakeBuilder) set(r builderResult) { + b.next.Store(r) + if r.cfg != nil { + b.cfg.Store(r.cfg) + } +} + +func (b *fakeBuilder) build() (*pipeline.Pipeline, *pipeline.Pipeline, *config.Config, error) { + r := b.next.Load().(builderResult) + return r.inbound, r.outbound, r.cfg, r.err +} + +// emptyPipeline returns a no-plugin pipeline, used for tests that +// don't exercise plugin behavior — only swap plumbing. +func emptyPipeline(t *testing.T) *pipeline.Pipeline { + t.Helper() + p, err := pipeline.New(nil) + if err != nil { + t.Fatalf("pipeline.New: %v", err) + } + return p +} + +// writeConfig writes `content` atomically into path via rename, the +// same sequence an editor / kubectl apply ultimately performs. +func writeConfig(t *testing.T, path string, content string) { + t.Helper() + tmp := path + ".tmp" + if err := os.WriteFile(tmp, []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + if err := os.Rename(tmp, path); err != nil { + t.Fatalf("Rename: %v", err) + } +} + +// waitFor polls `cond` up to `timeout` at 10ms intervals. Used to +// observe async reload completion without baked-in sleeps. +func waitFor(t *testing.T, timeout time.Duration, cond func() bool, msg string) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("timed out waiting for %s", msg) +} + +// setup creates a temp dir with an initial config file, builds a +// Reloader with short debounce + no drain window, and Starts it. +func setup(t *testing.T) (*Reloader, *fakeBuilder, string, *pipeline.Holder, *pipeline.Holder) { + t.Helper() + + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfgPath, []byte("mode: envoy-sidecar\n"), 0o600); err != nil { + t.Fatalf("initial WriteFile: %v", err) + } + + initialCfg := &config.Config{Mode: "envoy-sidecar"} + inP := emptyPipeline(t) + outP := emptyPipeline(t) + inH := pipeline.NewHolder(inP) + outH := pipeline.NewHolder(outP) + + b := &fakeBuilder{} + // Seed with a result for the first reload. Tests overwrite this + // before the config file is next written. + b.set(builderResult{inbound: emptyPipeline(t), outbound: emptyPipeline(t), cfg: initialCfg}) + + r := New(cfgPath, inH, outH, b.build, initialCfg, + WithDebounce(20*time.Millisecond), + WithDrainWindow(0), + WithStartTimeout(5*time.Second), + ) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + if err := r.Start(ctx); err != nil { + t.Fatalf("Start: %v", err) + } + + return r, b, cfgPath, inH, outH +} + +// Plain file rewrite → reload fires; the pipeline Holders point at the +// new pipelines after the swap. +func TestReloader_PlainRewrite(t *testing.T) { + r, b, cfgPath, inH, outH := setup(t) + + newIn := emptyPipeline(t) + newOut := emptyPipeline(t) + b.set(builderResult{inbound: newIn, outbound: newOut, cfg: &config.Config{Mode: "envoy-sidecar"}}) + + writeConfig(t, cfgPath, "mode: envoy-sidecar\n# edited\n") + + waitFor(t, 2*time.Second, func() bool { return r.Status().ReloadsOK >= 1 }, "reload to succeed") + if inH.Load() != newIn { + t.Errorf("inbound holder: not swapped") + } + if outH.Load() != newOut { + t.Errorf("outbound holder: not swapped") + } +} + +// Two writes within the debounce window coalesce into a single reload. +func TestReloader_DebouncesBurst(t *testing.T) { + r, b, cfgPath, _, _ := setup(t) + b.set(builderResult{inbound: emptyPipeline(t), outbound: emptyPipeline(t), cfg: &config.Config{Mode: "envoy-sidecar"}}) + + writeConfig(t, cfgPath, "mode: envoy-sidecar\n# edit 1\n") + writeConfig(t, cfgPath, "mode: envoy-sidecar\n# edit 2\n") + + waitFor(t, 2*time.Second, func() bool { return r.Status().ReloadsOK >= 1 }, "first reload") + // Give the debounce a chance to fire a second time if it's going to. + time.Sleep(100 * time.Millisecond) + + if got := r.Status().ReloadsOK; got > 1 { + t.Errorf("expected coalesced single reload, got %d", got) + } +} + +// A write that produces identical bytes is a no-op — no reload, no +// failed-counter bump. +func TestReloader_ContentHashDedup(t *testing.T) { + r, _, cfgPath, _, _ := setup(t) + before := r.Status() + + // Touch the file with the same content it already had. + data, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + writeConfig(t, cfgPath, string(data)) + + time.Sleep(200 * time.Millisecond) + + after := r.Status() + if after.ReloadsOK != before.ReloadsOK || after.ReloadsFailed != before.ReloadsFailed { + t.Errorf("counters changed on no-op write: before=%+v after=%+v", before, after) + } +} + +// Mode change is unreloadable; reload is refused; holders unchanged. +func TestReloader_RefusesModeChange(t *testing.T) { + r, b, cfgPath, inH, _ := setup(t) + + oldInPipeline := inH.Load() + newIn := emptyPipeline(t) + newOut := emptyPipeline(t) + b.set(builderResult{inbound: newIn, outbound: newOut, cfg: &config.Config{Mode: "waypoint"}}) + + writeConfig(t, cfgPath, "mode: waypoint\n") + + waitFor(t, 2*time.Second, func() bool { return r.Status().ReloadsFailed >= 1 }, "reload to fail") + if inH.Load() != oldInPipeline { + t.Errorf("inbound holder mutated despite refused reload") + } + if r.Status().LastError == "" || !contains(r.Status().LastError, "mode") { + t.Errorf("error should mention mode, got %q", r.Status().LastError) + } +} + +// Listener address change is unreloadable. +func TestReloader_RefusesListenerChange(t *testing.T) { + r, b, cfgPath, inH, _ := setup(t) + + oldInPipeline := inH.Load() + newCfg := &config.Config{ + Mode: "envoy-sidecar", + Listener: config.ListenerConfig{ExtProcAddr: ":19090"}, // different from active (empty) + } + b.set(builderResult{inbound: emptyPipeline(t), outbound: emptyPipeline(t), cfg: newCfg}) + + writeConfig(t, cfgPath, "mode: envoy-sidecar\nlistener: {ext_proc_addr: :19090}\n") + + waitFor(t, 2*time.Second, func() bool { return r.Status().ReloadsFailed >= 1 }, "reload to fail") + if inH.Load() != oldInPipeline { + t.Errorf("inbound holder mutated despite refused reload") + } + if got := r.Status().LastError; !contains(got, "listener") { + t.Errorf("error should mention listener, got %q", got) + } +} + +// PipelineBuilder error (e.g., config.Validate rejects) → reload fails; +// holders unchanged; error reflected in Status. +func TestReloader_BuilderError(t *testing.T) { + r, b, cfgPath, inH, _ := setup(t) + oldInPipeline := inH.Load() + + b.set(builderResult{err: &fakeErr{"synthetic build error"}}) + writeConfig(t, cfgPath, "mode: envoy-sidecar\n# any edit\n") + + waitFor(t, 2*time.Second, func() bool { return r.Status().ReloadsFailed >= 1 }, "reload to fail") + if inH.Load() != oldInPipeline { + t.Errorf("holder mutated despite failed build") + } + if !contains(r.Status().LastError, "synthetic build error") { + t.Errorf("error should name the builder's error, got %q", r.Status().LastError) + } +} + +// Config provider returns the latest-active *Config after a swap. +func TestReloader_ConfigProviderReflectsSwap(t *testing.T) { + r, b, cfgPath, _, _ := setup(t) + provider := r.ConfigProvider() + + // Mutate a non-unreloadable field so the swap is accepted. Session + // TTL is a plain string field, easy to compare. + newCfg := &config.Config{ + Mode: "envoy-sidecar", + Session: config.SessionConfig{TTL: "15m"}, + } + b.set(builderResult{inbound: emptyPipeline(t), outbound: emptyPipeline(t), cfg: newCfg}) + + writeConfig(t, cfgPath, "mode: envoy-sidecar\nsession: {ttl: 15m}\n") + waitFor(t, 2*time.Second, func() bool { return r.Status().ReloadsOK >= 1 }, "reload to succeed") + + got := provider() + if got.Session.TTL != "15m" { + t.Errorf("ConfigProvider: got TTL=%q, want 15m", got.Session.TTL) + } +} + +// --- helpers -------------------------------------------------------- + +type fakeErr struct{ msg string } + +func (e *fakeErr) Error() string { return e.msg } + +func contains(haystack, needle string) bool { + return len(haystack) >= len(needle) && indexOf(haystack, needle) >= 0 +} + +func indexOf(s, sub string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} diff --git a/authbridge/authlib/reloader/status.go b/authbridge/authlib/reloader/status.go new file mode 100644 index 000000000..2d3eeb136 --- /dev/null +++ b/authbridge/authlib/reloader/status.go @@ -0,0 +1,51 @@ +package reloader + +import ( + "encoding/json" + "log/slog" + "net/http" + "time" +) + +// Status is the JSON body of /reload/status. Snapshotted inside an +// atomic.Pointer — readers get a consistent view even if an in-flight +// reload mutates the fields a moment later. +type Status struct { + // LastAttempt is the timestamp of the most recent reload attempt, + // regardless of success. Zero until the first event. + LastAttempt time.Time `json:"last_attempt"` + + // LastSuccess is the timestamp of the most recent successful swap. + // Zero until the first success. Initialized to process-start time + // by New so operators see a non-zero value before the first reload. + LastSuccess time.Time `json:"last_success"` + + // LastError is the error string from the most recent failed reload, + // or empty after a success. Phrased for operators reading a JSON + // response, not a developer stack trace. + LastError string `json:"last_error"` + + // ReloadsOK counts successful swaps since process start. + ReloadsOK int64 `json:"reloads_ok"` + + // ReloadsFailed counts failed reload attempts since process start + // (validation, build, Start, or unreloadable-field rejection). + ReloadsFailed int64 `json:"reloads_failed"` + + // ActiveConfigSHA256 is the sha256 hex digest of the currently- + // active config file bytes. Lets operators cross-check which YAML + // revision is running. + ActiveConfigSHA256 string `json:"active_config_sha256"` +} + +// Handler returns an http.HandlerFunc that renders Status as JSON on +// GET /reload/status. Reads are lock-free via atomic.Pointer so the +// handler adds no contention with the reload path. +func (r *Reloader) Handler() http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(r.Status()); err != nil { + slog.Debug("reloader: status encode failed", "error", err) + } + } +} diff --git a/authbridge/authlib/sessionapi/server.go b/authbridge/authlib/sessionapi/server.go index 59a901f15..8580374d4 100644 --- a/authbridge/authlib/sessionapi/server.go +++ b/authbridge/authlib/sessionapi/server.go @@ -27,11 +27,15 @@ import ( const defaultHeartbeatInterval = 30 * time.Second // Server wraps an http.Server bound to a session store. +// +// inbound / outbound are holders (not raw pipelines) so a pipeline +// hot-swap under the running server is reflected in the next +// GET /v1/pipeline response without restarting. type Server struct { server *http.Server store *session.Store - inbound *pipeline.Pipeline - outbound *pipeline.Pipeline + inbound *pipeline.Holder + outbound *pipeline.Holder heartbeat time.Duration } @@ -44,10 +48,10 @@ func WithHeartbeatInterval(d time.Duration) Option { return func(s *Server) { s.heartbeat = d } } -// WithPipelines attaches the inbound and outbound pipelines so the server -// can expose their composition at GET /v1/pipeline. Either may be nil when -// a mode doesn't configure that direction. -func WithPipelines(inbound, outbound *pipeline.Pipeline) Option { +// WithPipelines attaches the inbound and outbound pipeline holders so +// the server can expose their current composition at GET /v1/pipeline. +// Either may be nil when a mode doesn't configure that direction. +func WithPipelines(inbound, outbound *pipeline.Holder) Option { return func(s *Server) { s.inbound = inbound s.outbound = outbound @@ -124,13 +128,14 @@ func (s *Server) handlePipeline(w http.ResponseWriter, _ *http.Request) { } } -// describePipeline turns a *pipeline.Pipeline into its wire form, or an -// empty slice when nil. -func describePipeline(p *pipeline.Pipeline, direction string) []pipelinePluginView { - if p == nil { +// describePipeline turns a *pipeline.Holder into its wire form, or an +// empty slice when nil. Loads through the Holder so a hot-swap that +// landed between requests is reflected immediately. +func describePipeline(h *pipeline.Holder, direction string) []pipelinePluginView { + if h == nil { return []pipelinePluginView{} } - plugins := p.Plugins() + plugins := h.Plugins() out := make([]pipelinePluginView, len(plugins)) for i, pl := range plugins { caps := pl.Capabilities() diff --git a/authbridge/authlib/sessionapi/server_test.go b/authbridge/authlib/sessionapi/server_test.go index e7ac96d27..2d73ea9f6 100644 --- a/authbridge/authlib/sessionapi/server_test.go +++ b/authbridge/authlib/sessionapi/server_test.go @@ -276,7 +276,7 @@ type fakePlugin struct { caps pipeline.PluginCapabilities } -func (f *fakePlugin) Name() string { return f.name } +func (f *fakePlugin) Name() string { return f.name } func (f *fakePlugin) Capabilities() pipeline.PluginCapabilities { return f.caps } func (f *fakePlugin) OnRequest(_ context.Context, _ *pipeline.Context) pipeline.Action { return pipeline.Action{Type: pipeline.Continue} @@ -303,7 +303,7 @@ func TestHandlePipeline(t *testing.T) { store := session.New(5*time.Minute, 100, 0) defer store.Close() - srv := New(":0", store, WithPipelines(inbound, outbound)) + srv := New(":0", store, WithPipelines(pipeline.NewHolder(inbound), pipeline.NewHolder(outbound))) ts := httptest.NewServer(srv.server.Handler) defer ts.Close() diff --git a/authbridge/authproxy/authbridge-combined.yaml b/authbridge/authproxy/authbridge-combined.yaml index a4b89928c..b4bcf5515 100644 --- a/authbridge/authproxy/authbridge-combined.yaml +++ b/authbridge/authproxy/authbridge-combined.yaml @@ -7,7 +7,7 @@ # authbridge/CLAUDE.md "Required ConfigMaps for Webhook Injection". # # Plugin settings live under pipeline.*.plugins[].config; see -# authbridge/authlib/plugins/CONVENTIONS.md for the per-plugin schema. +# authbridge/docs/plugin-reference.md for the per-plugin schema. # Fields omitted below fall back to plugin defaults — notably: # jwt-validation: audience_file=/shared/client-id.txt, bypass_paths # = .well-known/* + health/ready/live probes, diff --git a/authbridge/cmd/authbridge/README.md b/authbridge/cmd/authbridge/README.md index 5300df0e8..db8a63580 100644 --- a/authbridge/cmd/authbridge/README.md +++ b/authbridge/cmd/authbridge/README.md @@ -77,7 +77,7 @@ The `--mode` flag can also be set in the YAML config. The flag overrides the con YAML with `${ENV_VAR}` expansion. Undefined env vars are preserved as-is (not expanded to empty). -The runtime config is intentionally thin — it covers the mode, the listener addresses, session tracking, and the plugin pipeline. Everything a plugin needs (issuer, token URL, credentials, routes, bypass paths) lives under its own `config:` block inside the pipeline entry. See [`authlib/plugins/CONVENTIONS.md`](../../authlib/plugins/CONVENTIONS.md) for the per-plugin decode / defaults / validate convention. +The runtime config is intentionally thin — it covers the mode, the listener addresses, session tracking, and the plugin pipeline. Everything a plugin needs (issuer, token URL, credentials, routes, bypass paths) lives under its own `config:` block inside the pipeline entry. See [`docs/plugin-reference.md`](../../docs/plugin-reference.md) for the per-plugin decode / defaults / validate convention. ### envoy-sidecar mode diff --git a/authbridge/cmd/authbridge/go.mod b/authbridge/cmd/authbridge/go.mod index 20f23cce6..f91969cd3 100644 --- a/authbridge/cmd/authbridge/go.mod +++ b/authbridge/cmd/authbridge/go.mod @@ -15,6 +15,7 @@ require ( github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect + github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-json v0.10.3 // indirect github.com/lestrrat-go/blackmagic v1.0.3 // indirect diff --git a/authbridge/cmd/authbridge/go.sum b/authbridge/cmd/authbridge/go.sum index 127901f96..f63e27c28 100644 --- a/authbridge/cmd/authbridge/go.sum +++ b/authbridge/cmd/authbridge/go.sum @@ -11,6 +11,8 @@ github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNf github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 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= diff --git a/authbridge/cmd/authbridge/listener/extauthz/server.go b/authbridge/cmd/authbridge/listener/extauthz/server.go index a1da6d531..59ac6bdb2 100644 --- a/authbridge/cmd/authbridge/listener/extauthz/server.go +++ b/authbridge/cmd/authbridge/listener/extauthz/server.go @@ -19,10 +19,15 @@ import ( ) // Server implements the Envoy ext_authz Authorization gRPC service. +// +// InboundPipeline / OutboundPipeline are holders so the bound pipeline +// can be hot-swapped under the running listener; every Check() Loads +// through the holder, so in-flight requests finish on the pipeline they +// started with. type Server struct { authv3.UnimplementedAuthorizationServer - InboundPipeline *pipeline.Pipeline - OutboundPipeline *pipeline.Pipeline + InboundPipeline *pipeline.Holder + OutboundPipeline *pipeline.Holder } // Check handles a single ext_authz authorization request. diff --git a/authbridge/cmd/authbridge/listener/extauthz/server_test.go b/authbridge/cmd/authbridge/listener/extauthz/server_test.go index 3fd7a39e8..514f3bfba 100644 --- a/authbridge/cmd/authbridge/listener/extauthz/server_test.go +++ b/authbridge/cmd/authbridge/listener/extauthz/server_test.go @@ -42,7 +42,10 @@ func serverFromAuth(t *testing.T, a *authpkg.Auth) *Server { if err != nil { t.Fatalf("building outbound pipeline: %v", err) } - return &Server{InboundPipeline: inbound, OutboundPipeline: outbound} + return &Server{ + InboundPipeline: pipeline.NewHolder(inbound), + OutboundPipeline: pipeline.NewHolder(outbound), + } } func checkRequest(host, path, authHeader string) *authv3.CheckRequest { diff --git a/authbridge/cmd/authbridge/listener/extproc/server.go b/authbridge/cmd/authbridge/listener/extproc/server.go index f343389f7..777136c6e 100644 --- a/authbridge/cmd/authbridge/listener/extproc/server.go +++ b/authbridge/cmd/authbridge/listener/extproc/server.go @@ -26,10 +26,15 @@ import ( const maxBodySize = 1 << 20 // 1MB — matches Envoy's default per_stream_buffer_limit_bytes // Server implements the Envoy ext_proc ExternalProcessor gRPC service. +// +// InboundPipeline / OutboundPipeline are holders so the bound pipeline +// can be hot-swapped under the running listener; each Process stream +// Loads through the holder, so in-flight requests finish on the pipeline +// they started with. type Server struct { extprocv3.UnimplementedExternalProcessorServer - InboundPipeline *pipeline.Pipeline - OutboundPipeline *pipeline.Pipeline + InboundPipeline *pipeline.Holder + OutboundPipeline *pipeline.Holder Sessions *session.Store // nil when session tracking is disabled } @@ -190,14 +195,14 @@ func (s *Server) recordInboundSession(pctx *pipeline.Context) { } sid := inboundSessionID(pctx) ev := pipeline.SessionEvent{ - At: time.Now(), - Direction: pipeline.Inbound, + At: time.Now(), + Direction: pipeline.Inbound, Phase: pipeline.SessionRequest, A2A: snapshotA2A(pctx.Extensions.A2A), Invocations: snapshotInvocations(pctx.Extensions.Invocations, pipeline.InvocationPhaseRequest), - Plugins: plugins, - Identity: snapshotIdentity(pctx), - Host: pctx.Host, + Plugins: plugins, + Identity: snapshotIdentity(pctx), + Host: pctx.Host, } s.Sessions.Append(sid, ev) } @@ -228,14 +233,14 @@ func (s *Server) recordInboundReject(pctx *pipeline.Context, action pipeline.Act message = action.Violation.Reason } ev := pipeline.SessionEvent{ - At: time.Now(), + At: time.Now(), Direction: pipeline.Inbound, Phase: pipeline.SessionDenied, Invocations: snapshotInvocations(pctx.Extensions.Invocations, pipeline.InvocationPhaseRequest), - Plugins: snapshotPlugins(pctx.Extensions.Custom), - Identity: snapshotIdentity(pctx), - Host: pctx.Host, - StatusCode: status, + Plugins: snapshotPlugins(pctx.Extensions.Custom), + Identity: snapshotIdentity(pctx), + Host: pctx.Host, + StatusCode: status, Error: &pipeline.EventError{ Kind: "policy", Code: code, @@ -330,17 +335,17 @@ func (s *Server) recordInboundResponseSession(pctx *pipeline.Context) { } sid := inboundSessionID(pctx) ev := pipeline.SessionEvent{ - At: time.Now(), - Direction: pipeline.Inbound, + At: time.Now(), + Direction: pipeline.Inbound, Phase: pipeline.SessionResponse, A2A: snapshotA2A(pctx.Extensions.A2A), Invocations: snapshotInvocations(pctx.Extensions.Invocations, pipeline.InvocationPhaseResponse), - Plugins: plugins, - Identity: snapshotIdentity(pctx), - StatusCode: pctx.StatusCode, - Error: deriveError(pctx), - Host: pctx.Host, - Duration: durationSince(pctx.StartedAt), + Plugins: plugins, + Identity: snapshotIdentity(pctx), + StatusCode: pctx.StatusCode, + Error: deriveError(pctx), + Host: pctx.Host, + Duration: durationSince(pctx.StartedAt), } s.Sessions.Append(sid, ev) } diff --git a/authbridge/cmd/authbridge/listener/extproc/server_test.go b/authbridge/cmd/authbridge/listener/extproc/server_test.go index 42b08d2a9..bed976304 100644 --- a/authbridge/cmd/authbridge/listener/extproc/server_test.go +++ b/authbridge/cmd/authbridge/listener/extproc/server_test.go @@ -77,7 +77,10 @@ func serverFromAuth(t *testing.T, a *auth.Auth) *Server { if err != nil { t.Fatalf("building outbound pipeline: %v", err) } - return &Server{InboundPipeline: inbound, OutboundPipeline: outbound} + return &Server{ + InboundPipeline: pipeline.NewHolder(inbound), + OutboundPipeline: pipeline.NewHolder(outbound), + } } func makeHeaders(kvs ...string) *corev3.HeaderMap { @@ -394,7 +397,7 @@ func TestExtProc_BodyBuffering_Inbound(t *testing.T) { t.Fatal(err) } - srv := &Server{InboundPipeline: p, OutboundPipeline: outbound} + srv := &Server{InboundPipeline: pipeline.NewHolder(p), OutboundPipeline: pipeline.NewHolder(outbound)} body := []byte(`{"method":"tools/call","id":1,"params":{"name":"get_weather"}}`) stream := &mockStream{ @@ -462,7 +465,7 @@ func TestExtProc_BodyBuffering_Outbound(t *testing.T) { t.Fatal(err) } - srv := &Server{InboundPipeline: inbound, OutboundPipeline: p} + srv := &Server{InboundPipeline: pipeline.NewHolder(inbound), OutboundPipeline: pipeline.NewHolder(p)} body := []byte(`{"key":"value"}`) stream := &mockStream{ @@ -511,7 +514,7 @@ func TestExtProc_BodyTooLarge(t *testing.T) { t.Fatal(err) } - srv := &Server{InboundPipeline: p, OutboundPipeline: outbound} + srv := &Server{InboundPipeline: pipeline.NewHolder(p), OutboundPipeline: pipeline.NewHolder(outbound)} bigBody := make([]byte, maxBodySize+1) stream := &mockStream{ diff --git a/authbridge/cmd/authbridge/listener/forwardproxy/server.go b/authbridge/cmd/authbridge/listener/forwardproxy/server.go index 314270458..c9a35ee89 100644 --- a/authbridge/cmd/authbridge/listener/forwardproxy/server.go +++ b/authbridge/cmd/authbridge/listener/forwardproxy/server.go @@ -19,14 +19,18 @@ import ( const maxBodySize = 1 << 20 // 1MB — matches Envoy's default per_stream_buffer_limit_bytes // Server is an HTTP forward proxy that performs token exchange on outbound requests. +// +// OutboundPipeline is a holder so the bound pipeline can be hot-swapped +// under the running listener; each handleRequest Loads through it so +// in-flight requests finish on the pipeline they started with. type Server struct { - OutboundPipeline *pipeline.Pipeline + OutboundPipeline *pipeline.Holder Sessions *session.Store // nil when session tracking is disabled Client *http.Client } // NewServer creates a forward proxy server with a default HTTP client. -func NewServer(outbound *pipeline.Pipeline, sessions *session.Store) *Server { +func NewServer(outbound *pipeline.Holder, sessions *session.Store) *Server { return &Server{ OutboundPipeline: outbound, Sessions: sessions, diff --git a/authbridge/cmd/authbridge/listener/forwardproxy/server_test.go b/authbridge/cmd/authbridge/listener/forwardproxy/server_test.go index 547684da8..bbdf29893 100644 --- a/authbridge/cmd/authbridge/listener/forwardproxy/server_test.go +++ b/authbridge/cmd/authbridge/listener/forwardproxy/server_test.go @@ -27,13 +27,13 @@ func (m *mockVerifier) Verify(_ context.Context, _ string, _ string) (*validatio return m.claims, m.err } -func outboundPipelineFromAuth(t *testing.T, a *auth.Auth) *pipeline.Pipeline { +func outboundPipelineFromAuth(t *testing.T, a *auth.Auth) *pipeline.Holder { t.Helper() p, err := plugintesting.BuildPipeline([]pipeline.Plugin{plugintesting.NewTokenExchange(a)}) if err != nil { t.Fatalf("building outbound pipeline: %v", err) } - return p + return pipeline.NewHolder(p) } func TestForwardProxy_Exchange(t *testing.T) { @@ -170,7 +170,7 @@ func TestForwardProxy_BodyBuffering(t *testing.T) { })) defer backend.Close() - srv := &Server{OutboundPipeline: p, Client: http.DefaultClient} + srv := &Server{OutboundPipeline: pipeline.NewHolder(p), Client: http.DefaultClient} proxy := httptest.NewServer(srv.Handler()) defer proxy.Close() @@ -208,7 +208,7 @@ func TestForwardProxy_BodyTooLarge(t *testing.T) { })) defer backend.Close() - srv := &Server{OutboundPipeline: p, Client: http.DefaultClient} + srv := &Server{OutboundPipeline: pipeline.NewHolder(p), Client: http.DefaultClient} proxy := httptest.NewServer(srv.Handler()) defer proxy.Close() @@ -233,7 +233,7 @@ func TestForwardProxy_BodyTooLarge(t *testing.T) { func TestForwardProxy_NoBodyBuffering_WhenNotNeeded(t *testing.T) { a := auth.New(auth.Config{}) - p := outboundPipelineFromAuth(t, a) // default pipeline has no body-access plugins + p := outboundPipelineFromAuth(t, a) // default pipeline has no body-access plugins; already a Holder backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) diff --git a/authbridge/cmd/authbridge/listener/reverseproxy/server.go b/authbridge/cmd/authbridge/listener/reverseproxy/server.go index e71296920..069615e52 100644 --- a/authbridge/cmd/authbridge/listener/reverseproxy/server.go +++ b/authbridge/cmd/authbridge/listener/reverseproxy/server.go @@ -38,15 +38,19 @@ func (e *responseRejectedError) Error() string { } // Server is an HTTP reverse proxy with inbound JWT validation. +// +// InboundPipeline is a holder so the bound pipeline can be hot-swapped +// under the running listener; each handleRequest Loads through it so +// in-flight requests finish on the pipeline they started with. type Server struct { - InboundPipeline *pipeline.Pipeline + InboundPipeline *pipeline.Holder Sessions *session.Store // nil when session tracking is disabled proxy *httputil.ReverseProxy backend string } // NewServer creates a reverse proxy that forwards to the given backend URL. -func NewServer(inbound *pipeline.Pipeline, sessions *session.Store, backendURL string) (*Server, error) { +func NewServer(inbound *pipeline.Holder, sessions *session.Store, backendURL string) (*Server, error) { target, err := url.Parse(backendURL) if err != nil { return nil, err diff --git a/authbridge/cmd/authbridge/listener/reverseproxy/server_test.go b/authbridge/cmd/authbridge/listener/reverseproxy/server_test.go index 6fbd25c77..22f85c4bf 100644 --- a/authbridge/cmd/authbridge/listener/reverseproxy/server_test.go +++ b/authbridge/cmd/authbridge/listener/reverseproxy/server_test.go @@ -24,13 +24,13 @@ func (m *mockVerifier) Verify(_ context.Context, _ string, _ string) (*validatio return m.claims, m.err } -func inboundPipelineFromAuth(t *testing.T, a *auth.Auth) *pipeline.Pipeline { +func inboundPipelineFromAuth(t *testing.T, a *auth.Auth) *pipeline.Holder { t.Helper() p, err := plugintesting.BuildPipeline([]pipeline.Plugin{plugintesting.NewJWTValidation(a, false)}) if err != nil { t.Fatalf("building inbound pipeline: %v", err) } - return p + return pipeline.NewHolder(p) } func TestReverseProxy_AllowedRequest(t *testing.T) { @@ -137,7 +137,7 @@ func TestReverseProxy_BodyBuffering(t *testing.T) { if err != nil { t.Fatal(err) } - srv, err := NewServer(p, nil, backend.URL) + srv, err := NewServer(pipeline.NewHolder(p), nil, backend.URL) if err != nil { t.Fatal(err) } @@ -172,7 +172,7 @@ func TestReverseProxy_BodyTooLarge(t *testing.T) { if err != nil { t.Fatal(err) } - srv, err := NewServer(p, nil, backend.URL) + srv, err := NewServer(pipeline.NewHolder(p), nil, backend.URL) if err != nil { t.Fatal(err) } diff --git a/authbridge/cmd/authbridge/main.go b/authbridge/cmd/authbridge/main.go index 8a128afa7..3ffe6bd89 100644 --- a/authbridge/cmd/authbridge/main.go +++ b/authbridge/cmd/authbridge/main.go @@ -5,7 +5,9 @@ package main import ( "context" + "errors" "flag" + "fmt" "log" "log/slog" "net" @@ -28,6 +30,7 @@ import ( "github.com/kagenti/kagenti-extensions/authbridge/authlib/observe" "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/reloader" "github.com/kagenti/kagenti-extensions/authbridge/authlib/session" "github.com/kagenti/kagenti-extensions/authbridge/authlib/sessionapi" "github.com/kagenti/kagenti-extensions/authbridge/cmd/authbridge/listener/extauthz" @@ -86,36 +89,41 @@ func main() { log.Fatal("--config is required") } - // Load config - cfg, err := config.Load(*configPath) - if err != nil { - log.Fatalf("loading config: %v", err) - } - if *mode != "" { - cfg.Mode = *mode // flag overrides YAML - } - config.ApplyPreset(cfg) - if err := config.Validate(cfg); err != nil { - log.Fatalf("config validation: %v", err) + // buildPipelines loads the config from *configPath, applies mode + // override + presets, validates, and builds the plugin pipelines. + // Runs once at startup and again on every reload — the reloader + // holds this closure so both paths share exactly the same sequence. + // Returns a descriptive error on any step's failure so /reload/status + // surfaces an operator-readable message. + buildPipelines := func() (*pipeline.Pipeline, *pipeline.Pipeline, *config.Config, error) { + c, err := config.Load(*configPath) + if err != nil { + return nil, nil, nil, err + } + if *mode != "" { + c.Mode = *mode // flag overrides YAML + } + config.ApplyPreset(c) + if err := config.Validate(c); err != nil { + return nil, nil, nil, err + } + in, err := plugins.Build(c.Pipeline.Inbound.Plugins) + if err != nil { + return nil, nil, nil, fmt.Errorf("inbound: %w", err) + } + out, err := plugins.Build(c.Pipeline.Outbound.Plugins) + if err != nil { + return nil, nil, nil, fmt.Errorf("outbound: %w", err) + } + if c.Mode == config.ModeWaypoint && (in.NeedsBody() || out.NeedsBody()) { + return nil, nil, nil, errors.New("waypoint mode does not support plugins that require body access (ext_authz limitation)") + } + return in, out, c, nil } - // Build pipelines from config. Each plugin that implements - // pipeline.Configurable receives its own config subtree via - // Configure and constructs any internal state (JWKS verifier, - // token-exchange client, router) from it. No shared auth handler. - inboundPipeline, err := plugins.Build(cfg.Pipeline.Inbound.Plugins) + inboundPipeline, outboundPipeline, cfg, err := buildPipelines() if err != nil { - log.Fatalf("building inbound pipeline: %v", err) - } - outboundPipeline, err := plugins.Build(cfg.Pipeline.Outbound.Plugins) - if err != nil { - log.Fatalf("building outbound pipeline: %v", err) - } - - if cfg.Mode == config.ModeWaypoint { - if inboundPipeline.NeedsBody() || outboundPipeline.NeedsBody() { - log.Fatalf("waypoint mode does not support plugins that require body access (ext_authz limitation)") - } + log.Fatalf("initial pipeline build: %v", err) } // Invoke Init on any plugin implementing pipeline.Initializer. Done @@ -133,6 +141,21 @@ func main() { log.Fatalf("outbound pipeline Start: %v", err) } + // Wrap pipelines in Holders. Listeners reference the Holder rather + // than the *Pipeline directly so the reloader can swap the bound + // pipeline under a running listener without pod restart. + inboundH := pipeline.NewHolder(inboundPipeline) + outboundH := pipeline.NewHolder(outboundPipeline) + + // Arm the config file watcher. ctx is cancelled on SIGTERM/SIGINT + // so the reloader goroutine exits cleanly at shutdown. + ctx, cancelCtx := context.WithCancel(context.Background()) + defer cancelCtx() + rld := reloader.New(*configPath, inboundH, outboundH, buildPipelines, cfg) + if err := rld.Start(ctx); err != nil { + log.Fatalf("reloader: %v", err) + } + // Build session store if enabled (nil when disabled — zero overhead). // Defaults to on; set session.enabled: false in runtime config to opt out. var sessions *session.Store @@ -166,19 +189,19 @@ func main() { // Start listeners FIRST — before credential resolution switch cfg.Mode { case config.ModeEnvoySidecar: - grpcServers = append(grpcServers, startGRPCExtProc(inboundPipeline, outboundPipeline, sessions, cfg.Listener.ExtProcAddr)) + grpcServers = append(grpcServers, startGRPCExtProc(inboundH, outboundH, sessions, cfg.Listener.ExtProcAddr)) case config.ModeWaypoint: - grpcServers = append(grpcServers, startGRPCExtAuthz(inboundPipeline, outboundPipeline, cfg.Listener.ExtAuthzAddr)) - httpServers = append(httpServers, startHTTPServer("forward-proxy", forwardproxy.NewServer(outboundPipeline, sessions).Handler(), cfg.Listener.ForwardProxyAddr)) + grpcServers = append(grpcServers, startGRPCExtAuthz(inboundH, outboundH, cfg.Listener.ExtAuthzAddr)) + httpServers = append(httpServers, startHTTPServer("forward-proxy", forwardproxy.NewServer(outboundH, sessions).Handler(), cfg.Listener.ForwardProxyAddr)) case config.ModeProxySidecar: - rpSrv, err := reverseproxy.NewServer(inboundPipeline, sessions, cfg.Listener.ReverseProxyBackend) + rpSrv, err := reverseproxy.NewServer(inboundH, sessions, cfg.Listener.ReverseProxyBackend) if err != nil { log.Fatalf("creating reverse proxy: %v", err) } httpServers = append(httpServers, startHTTPServer("reverse-proxy", rpSrv.Handler(), cfg.Listener.ReverseProxyAddr)) - httpServers = append(httpServers, startHTTPServer("forward-proxy", forwardproxy.NewServer(outboundPipeline, sessions).Handler(), cfg.Listener.ForwardProxyAddr)) + httpServers = append(httpServers, startHTTPServer("forward-proxy", forwardproxy.NewServer(outboundH, sessions).Handler(), cfg.Listener.ForwardProxyAddr)) default: log.Fatalf("unhandled mode %q", cfg.Mode) @@ -189,12 +212,14 @@ func main() { // *auth.Stats; the provider merges them into a single response // per HTTP request. Freshly-computed every call, so the numbers // reflect traffic up to the moment of the curl. + // Freshly computed per /stats request. Load through the Holder so a + // pipeline swap is reflected without restarting the stats handler. statsProvider := func() *auth.Stats { - sources := plugins.CollectStats(inboundPipeline) - sources = append(sources, plugins.CollectStats(outboundPipeline)...) + sources := plugins.CollectStats(inboundH.Load()) + sources = append(sources, plugins.CollectStats(outboundH.Load())...) return auth.MergeStats(sources...) } - statSrv := startStatServer(cfg, statsProvider) + statSrv := startStatServer(cfg, rld.ConfigProvider(), statsProvider, rld.Handler()) // Session events API (optional; only when session tracking is on). // The API has no authentication — bind only on in-cluster addresses and @@ -206,7 +231,7 @@ func main() { sessionAPISrv = sessionapi.New( cfg.Listener.SessionAPIAddr, sessions, - sessionapi.WithPipelines(inboundPipeline, outboundPipeline), + sessionapi.WithPipelines(inboundH, outboundH), ) go func() { slog.Warn("session API listening — UNAUTHENTICATED; contains raw user content; never expose via ingress", @@ -235,11 +260,13 @@ func main() { // Report the first not-ready plugin by name so operators // can diagnose from `kubectl describe pod` without // tailing container logs. - if name := inboundPipeline.NotReadyPlugin(); name != "" { + // Holder-delegated — a hot-reloaded pipeline's readiness is + // reflected in the next /readyz probe. + if name := inboundH.NotReadyPlugin(); name != "" { http.Error(w, "inbound plugin not ready: "+name, http.StatusServiceUnavailable) return } - if name := outboundPipeline.NotReadyPlugin(); name != "" { + if name := outboundH.NotReadyPlugin(); name != "" { http.Error(w, "outbound plugin not ready: "+name, http.StatusServiceUnavailable) return } @@ -290,7 +317,7 @@ func main() { } } -func startGRPCExtProc(inbound, outbound *pipeline.Pipeline, sessions *session.Store, addr string) *grpc.Server { +func startGRPCExtProc(inbound, outbound *pipeline.Holder, sessions *session.Store, addr string) *grpc.Server { srv := grpc.NewServer() extprocv3.RegisterExternalProcessorServer(srv, &extproc.Server{ InboundPipeline: inbound, @@ -313,7 +340,7 @@ func startGRPCExtProc(inbound, outbound *pipeline.Pipeline, sessions *session.St return srv } -func startGRPCExtAuthz(inbound, outbound *pipeline.Pipeline, addr string) *grpc.Server { +func startGRPCExtAuthz(inbound, outbound *pipeline.Holder, addr string) *grpc.Server { srv := grpc.NewServer() authv3.RegisterAuthorizationServer(srv, &extauthz.Server{ InboundPipeline: inbound, @@ -350,10 +377,11 @@ func startHTTPServer(name string, handler http.Handler, addr string) *http.Serve return srv } -func startStatServer(config *config.Config, provider observe.StatsProvider) *observe.StatServer { - srv := observe.NewStatServer(config.Stats.StatsAddress, config, provider) +func startStatServer(cfg *config.Config, cfgProvider observe.ConfigProvider, statsProvider observe.StatsProvider, reloadStatus http.Handler) *observe.StatServer { + srv := observe.NewStatServer(cfg.Stats.StatsAddress, cfgProvider, statsProvider, + observe.WithReloadStatus(reloadStatus)) go func() { - slog.Info("stat server listening", "addr", config.Stats.StatsAddress) + slog.Info("stat server listening", "addr", cfg.Stats.StatsAddress) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("stat server: %v", err) } diff --git a/authbridge/demos/weather-agent/demo-with-abctl.md b/authbridge/demos/weather-agent/demo-with-abctl.md index fd6d17c6a..f0e894c85 100644 --- a/authbridge/demos/weather-agent/demo-with-abctl.md +++ b/authbridge/demos/weather-agent/demo-with-abctl.md @@ -276,4 +276,4 @@ Press `p` to pause stream rendering. The SSE connection stays open (events don't - **GitHub-issue demo** — same idea with **outbound token exchange** and scope-based access control: [demo-ui.md](../github-issue/demo-ui.md). In `abctl` you'll see an additional outbound `exchange` plugin activity on the pipeline pane, and different `targetAudience` values on outbound events. - **Advanced weather demo** — adds **AuthBridge on the tool side** so you can inspect a second set of inbound pipeline events when the agent calls the MCP tool: [demo-ui-advanced.md](./demo-ui-advanced.md). -- **The plugin pipeline spec** — if you want to understand the data structures (`pctx`, `Extensions`, `SessionEvent`, `GetState`/`SetState`), or integrate a custom plugin or sub-pipeline engine: [pipeline/README.md](../../authlib/pipeline/README.md). +- **The plugin pipeline spec** — if you want to understand the data structures (`pctx`, `Extensions`, `SessionEvent`, `GetState`/`SetState`), or integrate a custom plugin or sub-pipeline engine: [framework-architecture.md](../../docs/framework-architecture.md). diff --git a/authbridge/docs/framework-architecture.md b/authbridge/docs/framework-architecture.md new file mode 100644 index 000000000..967adacc8 --- /dev/null +++ b/authbridge/docs/framework-architecture.md @@ -0,0 +1,661 @@ +# pipeline — Framework Architecture Reference + +Framework-level reference for AuthBridge's plugin pipeline: types, composition, lifecycle, shared state, and the boundary with listeners. Pair this with the plugin-author docs: + +- **Tutorial** — [`plugin-tutorial.md`](./plugin-tutorial.md). Writing a plugin from scratch with runnable examples. +- **Plugin author reference** — [`plugin-reference.md`](./plugin-reference.md). Config patterns, invocation emission contract, registration rules. +- **Framework reference** — this file. Pipeline internals and Go surface. + +**Audience:** +- Framework maintainers editing `authbridge/authlib/pipeline/`. +- Plugin authors who need to understand pipeline composition, lifecycle hooks, the shared state shape, or the observability contract in depth. +- Anyone debugging the plugin flow via `abctl` or the `:9094` session API. + +**Scope:** +- The Go surface in `authbridge/authlib/pipeline/` and `authbridge/authlib/session/`. +- The observability contract carried by `SessionEvent` on the `:9094` API. +- What the pipeline *does* and *does not* own at the boundary with the listener. + +For step-by-step "how do I write a plugin?" content, see [`plugin-tutorial.md`](./plugin-tutorial.md) first. + +--- + +## 1. Mental model + +AuthBridge intercepts HTTP traffic in two directions and runs a **separate plugin chain** for each. Each chain has two **phases** — request (headers/body going to the upstream) and response (headers/body coming back). + +``` + Inbound (caller → this agent) + ┌────────────────────────────────────────────────────┐ + │ Request phase → jwt-validation │ + │ → a2a-parser │ + │ → session-recorder (implicit) │ + │ Response phase ← a2a-parser OnResponse │ + │ ← jwt-validation OnResponse │ + └────────────────────────────────────────────────────┘ + + Outbound (this agent → target service) + ┌────────────────────────────────────────────────────┐ + │ Request phase → route-resolver │ + │ → token-exchange │ + │ → mcp-parser / inference-parser │ + │ Response phase ← mcp-parser / inference-parser │ + │ ← token-exchange OnResponse │ + └────────────────────────────────────────────────────┘ +``` + +**Key properties:** +- Plugins execute **sequentially** within a phase. +- Response phase runs plugins in **reverse order** (last plugin sees the response first — LIFO, matches middleware conventions). +- Inbound and outbound are **separate `Pipeline` instances**. A plugin that cares about both directions is registered on both. +- All state shared between plugins within one request/response cycle lives on `*pipeline.Context` (`pctx`). +- Cross-request state (per-session telemetry) lives in the `session.Store`, accessed read-only via `pctx.Session`. + +--- + +## 2. The `Plugin` interface + +```go +type Plugin interface { + Name() string + Capabilities() PluginCapabilities + OnRequest(ctx context.Context, pctx *Context) Action + OnResponse(ctx context.Context, pctx *Context) Action +} +``` + +### `Name() string` +A stable identifier. Used for logs, metrics, `GetState`/`SetState` keys (by convention), and pipeline introspection (`GET /v1/pipeline`). + +### `Capabilities() PluginCapabilities` + +```go +type PluginCapabilities struct { + Reads []string // extension slot names this plugin reads + Writes []string // extension slot names this plugin writes + BodyAccess bool // whether this plugin needs request/response body buffered +} +``` + +Declared once per plugin instance. `pipeline.New` validates that every `Read` is satisfied by an earlier plugin's `Write` — a plugin that depends on `mcp` being populated cannot be registered before `mcp-parser`. A mis-ordered registration fails fast at startup with: + +``` +plugin "guardrail" reads slot "mcp" but no earlier plugin writes it +``` + +`BodyAccess: true` on *any* plugin in a chain causes `Pipeline.NeedsBody()` to return true, which the **listener** uses to negotiate Envoy's `ProcessingMode` (BUFFERED vs HEADERS-only). Without this, the gRPC ext_proc server never asks for the body and parsers see `pctx.Body == nil`. + +### `OnRequest(ctx, pctx) Action` +Called when a request is entering the pipeline. Plugins typically read request headers / body, mutate one or more extension slots, and return `Continue` or `Reject`. + +### `OnResponse(ctx, pctx) Action` +Called after the upstream returns. `pctx.StatusCode`, `pctx.ResponseHeaders`, and `pctx.ResponseBody` are populated. Plugins typically enrich the telemetry extensions with response-side data (completion text, token usage, error code) or apply guardrails on the response content. + +Plugins that only care about the request set `OnResponse` to a no-op (`return Action{Type: Continue}`); same for response-only plugins on `OnRequest`. + +--- + +## 3. `pipeline.Context` — the shared state + +The entire surface a plugin sees: + +```go +type Context struct { + Direction Direction // Inbound | Outbound + Method string // HTTP method + Host string // :authority / Host + Path string // :path + Headers http.Header + Body []byte // nil unless a plugin declared BodyAccess: true + StartedAt time.Time // listener wall-clock at request entry + + Agent *AgentIdentity // this workload's SPIFFE / Keycloak identity + Claims *validation.Claims // inbound caller's JWT claims after jwt-validation + Route *routing.ResolvedRoute // outbound: resolved audience / token scopes + Session *SessionView // read-only view of the session bucket + + // Response-phase fields (populated by listener before RunResponse) + StatusCode int + ResponseHeaders http.Header + ResponseBody []byte + + Extensions Extensions +} +``` + +**Ownership rules:** +- Plugins **read** any field they declared in `Capabilities.Reads`. +- Plugins **write** fields they declared in `Capabilities.Writes`. By convention each extension slot has exactly one writer (the parser plugin). +- `Claims` is populated by `jwt-validation` and is read-only afterward. +- `Agent`, `Route`, `Session` are populated by the listener before `Run`. Plugins treat them as read-only. +- `ResponseBody` appears between `Run` and `RunResponse` — plugins must not read it in `OnRequest`. + +**Framework-owned attribution.** `pipeline.Run` / `RunResponse` stamp the currently-dispatching plugin's name and phase onto unexported fields of `pctx` around each plugin call. These drive the `pctx.Record` family of helpers so Invocation entries are auto-attributed without plugin-side ceremony. Plugins can't set them directly (unexported); exported `SetCurrentPlugin` / `ClearCurrentPlugin` exist for test harnesses that invoke plugins outside a `Pipeline.Run` dispatch loop. + +**Recording Invocations.** Plugins emit per-call diagnostic records through Context helpers: + +```go +pctx.Allow("authorized") // gate approved +pctx.Skip("path_bypass") // plugin ran but didn't act +pctx.Observe("matched_tools/call") // parser extracted data +pctx.Modify("token_replaced") // plugin mutated the message +pctx.Record(pipeline.Invocation{ // full form with diagnostic fields + Action: pipeline.ActionDeny, + Reason: "jwt_failed", + ExpectedIssuer: issuer, +}) +return pctx.DenyAndRecord(reason, code, message) // emit + reject in one call +``` + +Framework fills `Plugin`, `Phase`, `Path`; authors supply only what's specific to this call. See [`plugin-reference.md`](./plugin-reference.md#emitting-session-events) for the full 5-value action vocabulary and field reference. + +**Lifetime:** one `*Context` per HTTP transaction. Not reused across requests. Single-threaded — the pipeline guarantees sequential invocation of plugins within a phase, so plugins don't need internal locking for pctx reads/writes. + +--- + +## 4. `Extensions` — typed plugin-to-plugin communication + +```go +type Extensions struct { + MCP *MCPExtension + A2A *A2AExtension + Security *SecurityExtension + Delegation *DelegationExtension + Inference *InferenceExtension + Invocations *Invocations // per-plugin action records for every plugin that ran + Custom map[string]any // plugin-private state + escape-hatch public events +} +``` + +Three categories of cross-plugin / cross-phase state: + +### Invocations — per-plugin action record (always recorded) + +Every plugin that runs on a pipeline pass appends at least one `Invocation` to this slot via the `pctx.Record` family of helpers. The listener snapshots it onto `SessionEvent.Invocations` so `abctl` and `/v1/sessions` see a per-plugin timeline. + +```go +type Invocation struct { + Plugin string // plugin.Name(); framework-filled + Action InvocationAction // 5-value: allow | deny | skip | modify | observe + Phase InvocationPhase // "request" | "response"; framework-filled + Reason string // machine-stable code, e.g. "path_bypass" + Path string // request path; framework-filled + + // Optional diagnostic fields (populated selectively): + ExpectedIssuer, ExpectedAudience string + TokenSubject string + TokenAudience, TokenScopes []string + RouteMatched bool + RouteHost, TargetAudience string + RequestedScopes []string + CacheHit bool +} + +type Invocations struct { + Inbound []Invocation + Outbound []Invocation +} +``` + +Every plugin is expected to call one of `pctx.Allow` / `Skip` / `Observe` / `Modify` / `Record` / `DenyAndRecord` per active phase — see [`plugin-reference.md`](./plugin-reference.md#emitting-session-events) for the full field reference and 5-value vocabulary. + +### Named protocol slots (telemetry-worthy, optional per plugin) +MCP, A2A, Inference, plus Security and Delegation. These are: +- Part of the **published schema** carried on `SessionEvent` to `:9094` / `abctl`. +- Consumable by multiple downstream plugins. +- Added to the core struct only when the data has a public contract. + +A parser populates its slot AND records an Invocation with `ActionObserve`. The slot carries the structured payload (method, token counts, etc.); the Invocation carries the attribution. + +Adding a named slot is an authlib-core change: edit `Extensions`, add a wire field on `sessionEventWire`, update `snapshotXXX` helpers in the listener, and add filtering rules in `abctl`. + +### `Custom map[string]any` — plugin-private state + escape-hatch public events +Two access patterns share the same map, disambiguated by key suffix. + +**Plugin-private cross-phase state.** Use `GetState[T]` / `SetState[T]`: + +```go +// Plugin's private state type: +type rlState struct { + TokensAtStart int + Decision string +} + +// In OnRequest: +pipeline.SetState(pctx, "rate-limiter", &rlState{TokensAtStart: 100}) + +// In OnResponse: +s := pipeline.GetState[rlState](pctx, "rate-limiter") +if s != nil { /* use s */ } +``` + +Convention: **key = plugin's Name()** so collisions across plugins don't happen. Storage is lazy (`Custom` is nil-initialized until first write). + +`GetState[T]` type-asserts and returns `nil` on mismatch instead of panicking — a plugin whose type evolves across versions degrades gracefully. + +**Plugin-public escape-hatch events.** Write a key ending in `pipeline.PluginEventSuffix` (`"/event"`) with a JSON-marshalable value; the listener promotes it to `SessionEvent.Plugins[pluginName]` on the wire: + +```go +pctx.Extensions.Custom["rate-limiter" + pipeline.PluginEventSuffix] = rateLimiterEvent{ + Allowed: true, + TokensLeft: 42, +} +``` + +The suffix is the opt-in marker — private state stays out of the session stream. Graduate to a named slot when two or more plugins share the shape. See [`plugin-reference.md`](./plugin-reference.md#emitting-session-events) for the graduation criteria. + +### Built-in extension shapes + +All at `authbridge/authlib/pipeline/extensions.go`: + +```go +type MCPExtension struct { + Method string // JSON-RPC method, e.g. "tools/call" + RPCID any // JSON-RPC id (could be int or string) + Params map[string]any // request params + Result map[string]any // response result (mutually exclusive with Err) + Err *MCPError +} + +type A2AExtension struct { + Method string + RPCID any + SessionID string // contextId from the client, or server-assigned on first turn + MessageID string + TaskID string + Role string // "user" | "agent" + Parts []A2APart + FinalStatus string // response: "completed" | "failed" | "canceled" + Artifact string // response: assembled artifact text + ErrorMessage string // response: failure reason +} + +type InferenceExtension struct { + // Request side: + Model string + Messages []InferenceMessage + Temperature *float64 + MaxTokens *int + TopP *float64 + Stream bool + Tools []InferenceTool // full definition incl. parameters schema + ToolChoice any + // Response side: + Completion string + FinishReason string + PromptTokens int + CompletionTokens int + TotalTokens int + ToolCalls []InferenceToolCall +} + +type SecurityExtension struct { + Labels []string // classifier / guardrail output + Blocked bool + BlockReason string +} + +type DelegationExtension struct { + Origin string // original caller subject + Actor string // current actor subject + // chain is append-only via AppendHop; reads via Chain() +} +``` + +Mutability: **always assigned, never mutated in place** after the parser sets the slot. This guarantees that `snapshotXXX` in the listener (shallow-copy for event recording) stays correct even when OnResponse enriches the struct — the response snapshot is taken from the now-enriched pointer, but any earlier request-phase snapshot was taken of a frozen copy. + +--- + +## 5. `Action` — control flow + +```go +type Action struct { + Type ActionType // Continue | Reject + Violation *Violation // populated iff Type == Reject +} + +type Violation struct { + // Structured machine-readable error: + Code string // machine-readable, e.g. "auth.missing-token" + Reason string // short human message + Description string // longer explanation; optional + Details map[string]any // plugin-arbitrary structured context; optional + + // HTTP rendering hints — all optional; defaults from Code: + Status int // when 0, StatusFromCode(Code) is used + Body []byte // when nil, synthesized JSON + BodyType string // Content-Type for Body; defaults to application/json + Headers http.Header // merged into the response (e.g. WWW-Authenticate, Retry-After) + + // Framework-populated from Plugin.Name(); plugins leave it empty: + PluginName string +} +``` + +Returning `Reject` from `OnRequest` halts the request pipeline; from `OnResponse` halts the response pipeline. The listener calls `Violation.Render()` to produce `(status, headers, body)` and emits that as the HTTP response. The default body when `Body` is nil: + +```json +{ + "error": "auth.missing-token", + "message": "Bearer token required", + "description": "No Authorization header present", + "plugin": "jwt-validation", + "details": { "realm": "kagenti" } +} +``` + +Helper constructors cover the common cases so the reject site stays one line: + +```go +pipeline.Deny("auth.invalid-token", "token expired") +pipeline.DenyStatus(451, "policy.forbidden", "unavailable for legal reasons") +pipeline.DenyWithDetails("policy.rate-limited", "quota hit", map[string]any{ + "remaining": 0, "window": "1h", +}) +pipeline.Challenge("kagenti", "Authorization required") // 401 + WWW-Authenticate +pipeline.RateLimited(30*time.Second, "", "slow down") // 429 + Retry-After +``` + +The `Code` → HTTP-status mapping for well-known codes lives at `codeToStatus` in `action.go`; unknown codes default to 500. Plugins that need a non-default status set `Violation.Status` explicitly or use `DenyStatus`. + +There is no "soft error" channel today — a plugin that wants to fail open logs and returns `Continue`. A future iteration may add a per-plugin `on_error` policy. + +--- + +## 6. `Pipeline` — composition and execution + +```go +func New(plugins []Plugin, opts ...Option) (*Pipeline, error) +func (p *Pipeline) Run(ctx context.Context, pctx *Context) Action // request phase +func (p *Pipeline) RunResponse(ctx context.Context, pctx *Context) Action // response phase (reverse) +func (p *Pipeline) Start(ctx context.Context) error // invoke Init on Initializer plugins +func (p *Pipeline) Stop(ctx context.Context) // invoke Shutdown on Shutdowner plugins +func (p *Pipeline) Plugins() []Plugin // defensive copy +func (p *Pipeline) NeedsBody() bool // OR over all plugins' BodyAccess +``` + +`New` validates capability wiring at startup: every `Read` must be satisfied by some earlier plugin's `Write`. + +### Plugin lifecycle (`Start` / `Stop`) + +Plugins that need one-time setup (load a model, warm a cache, register metrics, spawn a background goroutine) implement the optional `Initializer` interface: + +```go +type Initializer interface { + Init(ctx context.Context) error +} +``` + +Plugins that need graceful cleanup (flush audit events, close a connection, cancel a goroutine) implement `Shutdowner`: + +```go +type Shutdowner interface { + Shutdown(ctx context.Context) error +} +``` + +Both are **optional** via Go's type-assertion idiom — a plugin that doesn't need them simply doesn't implement them, and the pipeline skips it. Existing plugins (jwt-validation, a2a-parser, mcp-parser, inference-parser, token-exchange) don't implement these; they keep working unchanged. + +The host (e.g. `cmd/authbridge/main.go`) drives the lifecycle: + +```go +// After pipeline.New, before listeners accept traffic: +initCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second) +defer cancel() +if err := inboundPipeline.Start(initCtx); err != nil { + log.Fatalf("inbound pipeline Start: %v", err) // fail-fast on bad plugin init +} +if err := outboundPipeline.Start(initCtx); err != nil { + log.Fatalf("outbound pipeline Start: %v", err) +} + +// ... serve traffic ... + +// After listeners have drained on SIGTERM: +outboundPipeline.Stop(shutdownCtx) // reverse order within each pipeline +inboundPipeline.Stop(shutdownCtx) +``` + +Semantics: +- `Start` — Init runs **in declaration order**, fails fast on the first error. The returned error names the offending plugin. No Shutdown is invoked on plugins whose Init already ran successfully — the intent is hard-fail on startup, not unwind. +- `Stop` — Shutdown runs **in reverse declaration order (LIFO)** so a plugin that depends on an earlier plugin's resources can still use them while cleaning up. Best-effort: errors from one Shutdown are logged but do not stop the sequence. Bounded by the caller's ctx deadline. + +A minimal Init/Shutdown plugin example — a rate-limiter that refreshes its quota store in the background: + +```go +type RateLimiter struct { + store *quotaStore + cancel context.CancelFunc +} + +func (p *RateLimiter) Name() string { return "rate-limiter" } +func (p *RateLimiter) Capabilities() pipeline.PluginCapabilities { return pipeline.PluginCapabilities{} } + +func (p *RateLimiter) Init(ctx context.Context) error { + p.store = newQuotaStore() + bg, cancel := context.WithCancel(context.Background()) + p.cancel = cancel + go p.store.refreshLoop(bg, 10*time.Second) // lives until Shutdown + return nil +} + +func (p *RateLimiter) Shutdown(ctx context.Context) error { + p.cancel() // stop the refresh loop + return p.store.flush(ctx) // best-effort write-back of pending counters +} + +func (p *RateLimiter) OnRequest(ctx context.Context, pctx *pipeline.Context) pipeline.Action { + if !p.store.allow(pctx) { + return pipeline.RateLimited(30*time.Second, "", "quota exceeded") + } + return pipeline.Action{Type: pipeline.Continue} +} + +func (p *RateLimiter) OnResponse(context.Context, *pipeline.Context) pipeline.Action { + return pipeline.Action{Type: pipeline.Continue} +} +``` + +### Extension slots known to the validator + +Built-in: `mcp`, `a2a`, `security`, `delegation`, `inference`, `custom`. + +**For plugins that write new slot names:** use the `WithSlots` option: + +```go +pipeline, err := pipeline.New(plugins, + pipeline.WithSlots("provenance", "risk-score")) +``` + +This tells the validator those slot names are legal, so a downstream plugin can `Capabilities.Reads = []string{"provenance"}` without being rejected as "unknown slot". + +### Execution order +- Request phase: `plugins[0].OnRequest → plugins[1].OnRequest → …` +- Response phase: `plugins[N-1].OnResponse → plugins[N-2].OnResponse → …` +- A `Reject` from any plugin halts its phase immediately. +- `ctx.Err() != nil` between plugins also halts with `Reject{Status: 499}`. + +### Concurrency model +Always sequential. No priority / mode / fire-and-forget semantics yet. This is the 80% case for auth-and-parse pipelines; richer modes would require an executor layer above the current loop. + +--- + +## 7. `Session` + `SessionEvent` — the observability side-channel + +The pipeline itself is **in-band** (plugins alter request handling). Alongside it runs an **out-of-band** observability layer: the listener snapshots `pctx` into a `SessionEvent` after each phase and appends it to a per-session bucket in the `session.Store`. This store is what powers the `:9094` HTTP API and `abctl`. + +```go +type SessionEvent struct { + SessionID string // bucket the event landed in + At time.Time + Direction Direction // inbound | outbound + Phase SessionPhase // request | response | denied + A2A *A2AExtension // snapshot of pctx.Extensions.A2A + MCP *MCPExtension + Inference *InferenceExtension + Invocations *Invocations // per-plugin action records, filtered by phase + Plugins map[string]json.RawMessage // plugin-public events (escape-hatch /event suffix) + Identity *EventIdentity // Subject, ClientID, AgentID, Scopes + StatusCode int // response phase only + Error *EventError // populated on 4xx/5xx + Host string // :authority + TargetAudience string // outbound: resolved OAuth audience + Duration time.Duration // response: wall-clock since request entry +} +``` + +**Three phase values:** +- `request` — snapshot taken after the request pipeline completes, carrying request-phase invocations. +- `response` — snapshot taken after the response pipeline completes, carrying response-phase invocations. Status, duration, and response parser output live here. +- `denied` — terminal denial by a pipeline plugin (jwt-validation reject, token-exchange failure, guardrail block). Carries the request-phase invocations plus the Violation's structured `Error`. + +**Plugins do not touch `SessionEvent` directly.** The listener records events. Plugins append Invocations via `pctx.Record` / `Allow` / `Skip` / `Observe` / `Modify` / `DenyAndRecord`, populate extension slots (A2A / MCP / Inference) via assignment, and read `pctx.Session` (a `*SessionView`) when they want to correlate the current request with prior ones in the same conversation — e.g. a rate-limiter that counts a session's inference events. + +Wire format (`SessionEvent.MarshalJSON`) translates enums to strings and `Duration` to `DurationMs`. Round-trip stable — `json.Marshal(e) → json.Unmarshal → json.Marshal` is byte-identical. Tested at `pipeline/session_test.go:TestSessionEvent_JSONRoundTrip`. + +--- + +## 8. Boundary: pipeline vs listener + +The pipeline **does not own**: + +| Concern | Owner | Why | +|---|---|---| +| HTTP wire protocol (ext_proc gRPC, ext_authz, reverse/forward proxy) | `cmd/authbridge/listener/` | Each mode speaks a different wire; pipeline stays protocol-free | +| Body buffering negotiation (`ProcessingMode: BUFFERED`) | Listener reads `Pipeline.NeedsBody()` | Only listener can respond to the ext_proc handshake | +| JWT issuance, client registration, Keycloak admin calls | Outside the pipeline (agent sidecars / kagenti-operator) | Async concerns happening before/after any request flow | +| Session store writes (`Store.Append`) | Listener, called after each phase | Plugins see only the read-only `SessionView` | +| SSE streaming of events to abctl | `authlib/sessionapi` | Observability API, not a plugin concern | + +The pipeline **does own**: +- The `Plugin` interface contract. +- `pipeline.Context` structure and invariants. +- Validation of capability wiring at construction. +- Sequential dispatch and reject-short-circuit semantics. +- Typed extension slots and `GetState`/`SetState` helpers. +- The session-event *shape* (the listener uses it but doesn't define it). + +--- + +## 9. Config hot-reload + +Editing `authbridge-config-` no longer requires a pod restart. `authlib/reloader` watches the mounted config file and atomically swaps the inbound / outbound pipelines when content changes; listeners drain onto the new pipeline, old in-flight requests finish on the previous one. + +**The moving parts** + +| Component | Responsibility | +|---|---| +| `pipeline.Holder` | `atomic.Pointer[*Pipeline]` slot that listeners read every request. Delegating methods (`Run`, `RunResponse`, `NeedsBody`, `Ready`, `NotReadyPlugin`, `Plugins`) so call sites don't change. | +| `authlib/reloader.Reloader` | Owns the fsnotify watcher, debouncer, content-hash dedup, validation, and drain scheduling. | +| `main.go` | Provides a `PipelineBuilder` closure that mirrors the startup `Load → ApplyPreset → Validate → plugins.Build` sequence, so startup and reload run identical code. | + +**Operator workflow** + +```sh +# 1. Edit the mounted ConfigMap +kubectl edit configmap authbridge-config- -n +# (or: kubectl apply -f …) + +# 2. Wait ~60s for kubelet to sync the new content into the mount. +# For instant reload during testing, restart the pod instead. + +# 3. Confirm the swap via the stats port +kubectl port-forward -n deploy/ 9093:9093 & +curl http://localhost:9093/reload/status # last_success, counters, sha256 +curl http://localhost:9093/config # now-active config +``` + +**Reload lifecycle (what happens when the file changes)** + +1. fsnotify event on the parent directory — we watch `/etc/authbridge/`, not `/etc/authbridge/config.yaml` directly, because ConfigMap mounts use symlink swap (`..data → ..`). A direct file watch misses the retarget. +2. Debounce 250 ms so a symlink-swap's REMOVE+CREATE+CHMOD burst fires one reload, not three. +3. SHA-256 dedup — identical bytes are ignored (mtime-only touches don't trigger rebuilds). +4. `PipelineBuilder` runs: `config.Load` → mode override → `ApplyPreset` → `Validate` → `plugins.Build`. Any failure records the error in `Status.LastError` and leaves the active pipeline untouched. +5. Compare the new config to the active one on unreloadable fields (`Mode`, `Listener.*`); refuse if they differ (see below). +6. `Start` the new pipelines with a 60 s budget. On Start failure, `Stop` any partially-started pipelines so their goroutines don't leak. +7. `inboundH.Store(newIn)` + `outboundH.Store(newOut)` — new requests now route to the new pipelines. +8. Background goroutine: `time.Sleep(drainWindow)` (default 30 s) → `oldPipeline.Stop(ctx)` with a 15 s budget. In-flight requests that already Loaded the old pipeline finish against it. +9. `Status.LastSuccess`, `ReloadsOK`, `ActiveConfigSHA256` update atomically. + +**What's reloadable, what isn't** + +| Change | Reloadable? | Why | +|---|---|---| +| Plugin list (add / remove / reorder plugins) | ✅ | Pipeline is rebuilt from scratch | +| A plugin's `config:` subtree (issuer, bypass paths, routes, JWKS URL, etc.) | ✅ | Plugin's `Configure` runs again with new bytes | +| `session.*` (TTL, MaxEvents, MaxSessions) | ⚠️ Reloaded into the `*Config`, but the live session store is built at startup — changes don't take effect until pod restart | +| `mode` (`envoy-sidecar` / `waypoint` / `proxy-sidecar`) | ❌ | Different wire protocol + listener set; refuse reload | +| `listener.*` (ports) | ❌ | Bound sockets; refuse reload | + +For the unreloadable cases, `Status.LastError` names the field(s) that changed and `ReloadsFailed` bumps — the operator can see from `/reload/status` that a pod restart is required. + +**Validation guarantee: bad YAML never takes the pod down.** Any failure during Load / Validate / Build / Start results in the status being updated and the active pipeline continuing to serve traffic on the previous config. Only a successful end-to-end reload swaps the holders. + +**Non-reloadable choices elsewhere.** The in-memory session store, the stat server, the session API server, and the reloader itself are all process-scoped — they live from startup to shutdown. A change to `session.enabled`, `listener.session_api_addr`, or the reloader's own knobs (drain window, debounce) requires a pod restart. + +--- + +## 10. Writing a plugin + +For a step-by-step tutorial that walks through building a new plugin from scratch — minimal plugin, recording invocations, rejection, config, body access, out-of-tree packaging, testing — see [`plugin-tutorial.md`](./plugin-tutorial.md). + +For the plugin-author reference (config conventions, invocation field list, registration rules, 5-value action vocabulary), see [`plugin-reference.md`](./plugin-reference.md). + +This document stays focused on the pipeline framework internals — how plugins compose, how the shared state is shaped, how control flows. The two plugins-side docs build on top of it. + +--- + +## 11. Open questions + +- **Priority / on-error policies.** Plugins don't declare these today. If fail-open / fail-closed behavior becomes important to express per plugin, it would be added to `PluginCapabilities` (or a sibling metadata struct) and interpreted by `Pipeline`. +- **Body mutation semantics.** Today plugins generally don't rewrite `pctx.Body` or `pctx.ResponseBody`. If a plugin needs to modify the payload, we'd need a clear contract about whether downstream plugins see the modified or original bytes. +- **Execution modes.** The pipeline is sequential-only. Concurrent or fire-and-forget modes would require an executor layer; no concrete use case yet. + +--- + +## 12. Versioning + +The plugin interface is **not** semver-stable yet (AuthBridge is pre-1.0). Changes since the initial release: +- Added `BodyAccess` to `PluginCapabilities`. +- Added `WithSlots` to `New` for bridge-plugin slot registration. +- Added `GetState[T]` / `SetState[T]` generic helpers. +- Extended `A2AExtension` with response-side fields (TaskID, FinalStatus, Artifact, ErrorMessage). +- Extended `InferenceExtension` with structured tools + tool calls + TopP / ToolChoice. +- Added `SessionEvent.MarshalJSON`/`UnmarshalJSON` round-trip contract. +- **Breaking**: replaced `Action.Status`/`Action.Reason` with `Action.Violation` (see §5). Migration: use `Deny()`, `DenyStatus()`, `Challenge()`, `RateLimited()` helpers. +- Added optional `Initializer` / `Shutdowner` / `Readier` interfaces + `Pipeline.Start` / `Pipeline.Stop` (see §6). Existing plugins are unaffected because the interfaces are opt-in via type-assertion. +- Added `SessionDenied` phase and `recordInboundReject` in the listener so denials surface as session events with full diagnostic context. +- **Unified invocation contract**: `AuthExtension` + `InboundAuth` + `OutboundAuth` collapsed into `Invocations` + `Invocation`. Every plugin (gate, parser, future) emits an Invocation record per pipeline pass using the 5-value `InvocationAction` vocabulary (allow / deny / skip / modify / observe). `SessionEvent.Auth` is now `SessionEvent.Invocations`. +- **`pctx.Record` helpers**: `Allow` / `Skip` / `Observe` / `Modify` / `Record` / `DenyAndRecord` on `Context`. Framework-managed attribution (`currentPlugin`, `currentPhase`, `Path`) fills Invocation fields automatically. +- **Open plugin registry**: plugins self-register from `init()` via `plugins.RegisterPlugin`. Third-party plugins in external modules drop in via a side-effect import. Closed `registry` map literal removed. +- **Config hot-reload**: new `pipeline.Holder` (atomic wrapper) + `authlib/reloader` package (fsnotify-driven). Listeners receive `*Holder` instead of `*Pipeline`; the reloader atomically swaps the holder's contents when the config file changes. `mode` and `listener.*` edits are refused (pod restart required); any other change is picked up within the kubelet sync window (~60s). See §9. + +Breaking changes will be announced in `authbridge/CHANGELOG.md` (TBD) before a 1.0 tag. + +--- + +## 13. Cross-references + +**Plugin-author docs** (pair with this framework reference): + +- [`plugin-tutorial.md`](./plugin-tutorial.md) — step-by-step tutorial for writing a plugin. +- [`plugin-reference.md`](./plugin-reference.md) — plugin-author reference: config patterns, invocation emission contract, registration rules. + +**Package sources:** + +- `pipeline.go` — `Pipeline` type, `New`, `Run`, `RunResponse`, `Start`, `Stop`, `Plugins`, `NeedsBody`. +- `holder.go` — `Holder`, the atomic slot listeners hold in place of a raw `*Pipeline`. +- `plugin.go` — `Plugin` interface, `PluginCapabilities`, `Configurable`, `Initializer`, `Shutdowner`, `Readier`. +- `action.go` — `Action`, `ActionType`, `Violation`, helper constructors (`Deny`, `DenyStatus`, `DenyWithDetails`, `Challenge`, `RateLimited`), `StatusFromCode`. +- `context.go` — `Context`, `Direction`, `AgentIdentity`, and the `pctx.Record` / `Allow` / `Skip` / `Observe` / `Modify` / `DenyAndRecord` helpers. +- `extensions.go` — `Extensions` struct, `Invocation`, `Invocations`, `InvocationAction`, named protocol extensions, `GetState` / `SetState`. +- `session.go` — `SessionEvent`, `SessionView`, `SessionPhase`, marshalers. +- `authlib/reloader/` — `Reloader`, `Status`, `PipelineBuilder`, `WithDrainWindow` / `WithDebounce` / `WithStartTimeout`, `Handler()` (serves `/reload/status`). + +**Downstream integrators:** + +- `authlib/session/` — `Store`, `SessionSummary`, ring buffer, TTL / max-events caps. +- `authlib/sessionapi/` — HTTP API (`/v1/sessions`, `/v1/events`, `/v1/pipeline`) surfacing all of the above. +- `authlib/plugins/` — built-in plugin implementations and registry. +- `cmd/authbridge/listener/extproc/` — reference usage for all three phases. +- `cmd/abctl/` — TUI consumer of the session API, useful as a reference integrator. diff --git a/authbridge/authlib/plugins/CONVENTIONS.md b/authbridge/docs/plugin-reference.md similarity index 70% rename from authbridge/authlib/plugins/CONVENTIONS.md rename to authbridge/docs/plugin-reference.md index 61b77398e..759f9df93 100644 --- a/authbridge/authlib/plugins/CONVENTIONS.md +++ b/authbridge/docs/plugin-reference.md @@ -1,10 +1,20 @@ -# Plugin Config Conventions +# Plugin Author Reference + +**Audience:** plugin authors who already know the basics and need the +contract — field names, invariants, error behaviour, the rules that the +framework enforces at startup. + +**See also:** +- [`plugin-tutorial.md`](./plugin-tutorial.md) — step-by-step tutorial for writing a new plugin. +- [`framework-architecture.md`](./framework-architecture.md) — how the pipeline + composes plugins, the lifecycle, the Context / Extensions wire shape. How plugins under `authbridge/authlib/plugins/` receive, validate, and -apply their configuration. Everything here is convention — the framework -only requires `pipeline.Configurable` if the plugin has any config at all. -The rest of this document exists so that the sixth and tenth plugin -don't each invent their own style. +apply their configuration; emit session events; and register themselves +with the pipeline builder. Everything here is convention — the framework +only requires `pipeline.Configurable` if the plugin has any config at +all. The rest of this document exists so that the sixth and tenth +plugin don't each invent their own style. ## Scope @@ -274,57 +284,79 @@ authentication isn't happening. ## Emitting session events -Every plugin MUST emit at least one `Invocation` record per +Every plugin MUST emit at least one `Invocation` record per active `OnRequest` / `OnResponse` call. Plugins may also populate one of the typed protocol extensions (`MCP`, `A2A`, `Inference`) when they carry -structured semantic payload, and may additionally publish arbitrary -plugin-specific events through the `Custom` escape-hatch map. +structured semantic payload, and may additionally publish plugin- +specific events through the `Custom` escape-hatch map. + +> For a tutorial on emitting Invocations — the `pctx.Record` / `Allow` +> / `Skip` / `Observe` / `Modify` / `DenyAndRecord` helpers with +> runnable examples — see [`plugin-tutorial.md` Step 2](./plugin-tutorial.md#step-2--record-what-your-plugin-did). +> This section is the field-level reference for the `Invocation` +> struct, the 5-value action vocabulary, and the rules around the +> Custom escape-hatch map. -### 1. Invocation record (required for every plugin) +### 1. Invocation record — field reference An `Invocation` says *which* plugin ran and *what* it did, in a 5-value vocabulary shared across all plugins. abctl renders one row -per invocation — without an invocation record, a plugin's work is -invisible to the operator. +per invocation. Every plugin that runs on a pipeline pass produces +at least one. ```go -// Append one record per OnRequest/OnResponse call. Helper functions -// exist in each plugin package; the listener snapshot will pick them up -// from pctx.Extensions.Invocations. -pctx.Extensions.Invocations = &pipeline.Invocations{ - Inbound: []pipeline.Invocation{{ - Plugin: "jwt-validation", - Action: pipeline.ActionAllow, // 5-value verb - Reason: "authorized", // machine-stable reason code - Path: pctx.Path, - }}, +type Invocation struct { + Plugin string // plugin.Name(); framework-filled + Action InvocationAction // 5-value: allow | deny | skip | modify | observe + Phase InvocationPhase // "request" | "response"; framework-filled + Reason string // machine-stable code + Path string // request path; framework-filled + + // Optional diagnostic fields; populated selectively: + ExpectedIssuer, ExpectedAudience string + TokenSubject string + TokenAudience, TokenScopes []string + RouteMatched bool + RouteHost, TargetAudience string + RequestedScopes []string + CacheHit bool } ``` -The 5 actions and when to use them: +The framework fills `Plugin`, `Phase`, and `Path` when the plugin +emits via `pctx.Record` / `Allow` / `Skip` / `Observe` / `Modify` / +`DenyAndRecord`. A plugin may override those fields explicitly — but +only in test harnesses where the plugin runs outside a +`Pipeline.Run` dispatch loop. + +**The 5-value action vocabulary** (complete): | Action | Meaning | Example | |---|---|---| | `allow` | Gate plugin permitted the request | jwt-validation on valid token | | `deny` | Gate plugin rejected the request; pipeline stops | jwt-validation on bad token, token-exchange on IdP failure | -| `skip` | Plugin ran but didn't act on this message | jwt-validation on a bypass path; parser whose body didn't match | +| `skip` | Plugin ran but didn't act on this message | jwt-validation bypass path; parser whose body didn't match | | `modify` | Plugin mutated the message | token-exchange replaced the Authorization header | | `observe` | Plugin attached diagnostic data; flow unchanged | parsers extracting MCP / A2A / Inference state | `Reason` is a stable machine-readable label (e.g. `path_bypass`, `no_matching_route`, `jwt_failed`, `matched_tools/call`) that -discriminates within an Action value. Filters in abctl can match on +discriminates within an Action value. abctl filters can match either — `/skip` shows every skip action regardless of reason; `/path_bypass` narrows to that specific skip flavour. -Fields populated selectively: auth gates fill `ExpectedIssuer` / -`ExpectedAudience` / `Token*`; outbound routers fill `Route*` and -`CacheHit`; parsers typically fill only `Plugin` / `Action` / -`Reason` / `Path` because their semantic payload lives on the typed -extension slot. +**Which diagnostic fields to populate:** + +- Auth gates (jwt-validation and kin): `ExpectedIssuer`, + `ExpectedAudience`, `TokenSubject`, `TokenAudience`, `TokenScopes`. +- Outbound routers (token-exchange and kin): `RouteMatched`, + `RouteHost`, `TargetAudience`, `RequestedScopes`, `CacheHit`. +- Parsers: usually none — their semantic payload lives on the typed + extension slot (A2A / MCP / Inference). Emit with just Action + + Reason. -NEVER put raw tokens, signatures, or secrets in an `Invocation`. The -session store has no auth. +**NEVER put raw tokens, signatures, or secrets in an Invocation.** +The session store has no auth. ### 2. Named protocol extension (optional, for parsers) @@ -391,10 +423,75 @@ Graduate to a typed slot when ≥2 of these are true: Don't graduate speculatively — the map path has no cost if you stay in it. +## Registering a plugin + +A plugin advertises itself to the pipeline builder through `RegisterPlugin` +in its package `init()`. The registration is open — any package that +imports `authlib/plugins` can register a plugin, regardless of whether it +lives in this module. The pattern mirrors `database/sql` drivers and +`log/slog` handlers. + +> For a step-by-step walkthrough (in-tree file layout, out-of-tree +> module + side-effect import, operator YAML wiring), see +> [`plugin-tutorial.md` Step 6](./plugin-tutorial.md#step-6--out-of-tree-plugins). This +> section is the field-level reference: the factory shape and the +> panic-on-misuse guarantees that define the registry's contract. + +### Factory shape + +```go +// authbridge/authlib/plugins/jwtvalidation.go +func init() { + RegisterPlugin("jwt-validation", func() pipeline.Plugin { return NewJWTValidation() }) +} +``` + +The factory is called once per pipeline instance during `Build`. It must +return a fresh `pipeline.Plugin`; the registry does not cache the returned +value. Two pipeline entries with the same name produce two independent +plugin instances, each decoded from its own `config:` block. + +### Rules and guardrails + +- **Double-registration panics.** If two packages both register under the + same name, the second call panics at process start. This is the + correct behaviour: silent last-write-wins would let a version + conflict poison the pipeline composition in ways that only surface as + mysterious runtime behaviour. +- **Empty name panics.** An empty plugin name cannot be referenced from + YAML; registering under one is a programmer bug, not a recoverable + condition. +- **Nil factory panics.** A nil factory would defer the crash until + `Build` tried to call it; panic at registration is closer to the bug. +- **Unknown plugin fails Build.** `Build` rejects entries whose name + isn't in the registry; the error message includes every registered + name so typos are easy to spot. + +### Testing against the registry + +Tests that need a fake plugin use `RegisterPlugin` + `t.Cleanup` with +`UnregisterPlugin`: + +```go +func TestMyScenario(t *testing.T) { + plugins.RegisterPlugin("fake-auth", func() pipeline.Plugin { + return &fakeAuth{} + }) + t.Cleanup(func() { plugins.UnregisterPlugin("fake-auth") }) + + p, err := plugins.Build([]config.PluginEntry{{Name: "fake-auth"}}) + // ... assert on p ... +} +``` + +`UnregisterPlugin` is test-only by convention — production code should +never call it. It exists to keep tests isolated from each other under +`-parallel`. + ## Cross-references - `authbridge/authlib/pipeline/configurable.go` — the interface. -- `authbridge/authlib/pipeline/README.md` — how plugins compose and +- `authbridge/docs/framework-architecture.md` — how plugins compose and run; Configure's place in the lifecycle. - `authbridge/authlib/config/config.go` — `PluginEntry` YAML shape and parsing. diff --git a/authbridge/docs/plugin-tutorial.md b/authbridge/docs/plugin-tutorial.md new file mode 100644 index 000000000..07600c14a --- /dev/null +++ b/authbridge/docs/plugin-tutorial.md @@ -0,0 +1,302 @@ +# Writing a Plugin + +**Audience:** someone building their first authbridge plugin. Walks +from an empty file to a fully-registered plugin with config, recording, +body access, and tests. + +**See also:** +- [`plugin-reference.md`](./plugin-reference.md) — field-level reference for config, + invocation recording, and the registration contract. +- [`framework-architecture.md`](./framework-architecture.md) — how the pipeline + composes plugins and the lifecycle of a request. + +A step-by-step guide to building a new authbridge plugin. For reference-style +detail on config, registration, and invocation recording, see +[`plugin-reference.md`](./plugin-reference.md). + +## What a plugin is + +A plugin is a Go type that implements the `pipeline.Plugin` interface and +registers itself in the plugin registry. The pipeline invokes it on every +request (OnRequest) and, in reverse order, on every response (OnResponse). +Plugins can read `pctx`, mutate headers/body, reject the request, and record +diagnostic invocations that show up in `/v1/sessions` and in `abctl`. + +## Step 1 — The minimal plugin + +Create a file under `authbridge/authlib/plugins/hellolog.go`: + +```go +package plugins + +import ( + "context" + "log/slog" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" +) + +type HelloLog struct{} + +func NewHelloLog() *HelloLog { return &HelloLog{} } + +func (p *HelloLog) Name() string { return "hello-log" } + +func (p *HelloLog) Capabilities() pipeline.PluginCapabilities { + return pipeline.PluginCapabilities{} +} + +func (p *HelloLog) OnRequest(_ context.Context, pctx *pipeline.Context) pipeline.Action { + slog.Info("hello-log: request", "path", pctx.Path) + pctx.Observe("request_seen") + return pipeline.Action{Type: pipeline.Continue} +} + +func (p *HelloLog) OnResponse(_ context.Context, pctx *pipeline.Context) pipeline.Action { + slog.Info("hello-log: response", "status", pctx.StatusCode) + pctx.Observe("response_seen") + return pipeline.Action{Type: pipeline.Continue} +} + +func init() { + RegisterPlugin("hello-log", func() pipeline.Plugin { return NewHelloLog() }) +} +``` + +That's a complete plugin. Operator YAML adds it to a pipeline: + +```yaml +pipeline: + inbound: + plugins: + - name: jwt-validation + - name: hello-log +``` + +## Step 2 — Record what your plugin did + +Every plugin should tell the operator what it did on each message. +The pipeline fills in `Plugin`, `Phase`, and `Path` automatically — you +supply only the action and reason. Use the one-liner that fits: + +```go +pctx.Allow("authorized") // gate plugin approved +pctx.Skip("path_bypass") // plugin ran but didn't act +pctx.Observe("matched_tools/call") // parser extracted data +pctx.Modify("token_replaced") // plugin mutated the message +``` + +For invocations that carry extra diagnostic context, use `Record`: + +```go +pctx.Record(pipeline.Invocation{ + Action: pipeline.ActionAllow, + Reason: "authorized", + TokenSubject: claims.Subject, + TokenScopes: claims.Scopes, +}) +``` + +See [`plugin-reference.md`](./plugin-reference.md#emitting-session-events) for the +full field set and the 5-value action vocabulary. + +## Step 3 — Reject a request + +Return a `Reject` action when your plugin should stop the pipeline: + +```go +if !allowed { + return pipeline.Deny("policy.forbidden", "caller not permitted") +} +``` + +Helper constructors exist for the common cases: + +```go +pipeline.Deny(code, reason) // generic deny +pipeline.DenyStatus(401, code, reason) // override status +pipeline.Challenge("realm", "missing credentials") // 401 + WWW-Authenticate +pipeline.RateLimited(30*time.Second, "", "slow down") // 429 + Retry-After +``` + +When you want to emit an invocation AND reject in one call, use +`pctx.DenyAndRecord`: + +```go +return pctx.DenyAndRecord("caller_not_allowed", "policy.forbidden", "caller not permitted") +``` + +## Step 4 — Add config + +If your plugin needs configurable knobs, implement +`pipeline.Configurable`: + +```go +type HelloConfig struct { + Greeting string `json:"greeting"` +} + +type HelloLog struct { + cfg HelloConfig +} + +func (p *HelloLog) Configure(raw json.RawMessage) error { + if len(raw) == 0 { + p.cfg.Greeting = "hello" // default + return nil + } + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() // reject typos loudly + if err := dec.Decode(&p.cfg); err != nil { + return fmt.Errorf("hello-log config: %w", err) + } + if p.cfg.Greeting == "" { + p.cfg.Greeting = "hello" + } + return nil +} +``` + +Operator YAML: + +```yaml +- name: hello-log + config: + greeting: "hola" +``` + +See [`plugin-reference.md`](./plugin-reference.md#the-four-step-configure-pattern) +for the strict-decode / defaults / validate / construct pattern. + +## Step 5 — Body access + +If your plugin needs to read the request or response body (e.g., to +parse JSON, scan for credentials, or apply a content filter), declare +it in `Capabilities`: + +```go +func (p *HelloLog) Capabilities() pipeline.PluginCapabilities { + return pipeline.PluginCapabilities{BodyAccess: true} +} +``` + +The listener then tells Envoy to buffer the body so `pctx.Body` (request) +and `pctx.ResponseBody` (response) are populated. Without the declaration, +both stay nil even if you try to read them. + +## Step 6 — Out-of-tree plugins + +A plugin living in another Go module follows the same pattern, but +imports the registry instead of sharing its package: + +```go +// github.com/acme/my-plugin/myplugin.go +package myplugin + +import ( + "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins" +) + +type MyPlugin struct{} + +func (p *MyPlugin) Name() string { return "my-plugin" } +// ... Capabilities / OnRequest / OnResponse ... + +func init() { + plugins.RegisterPlugin("my-plugin", func() pipeline.Plugin { return &MyPlugin{} }) +} +``` + +The operator's authbridge build picks it up with a single side-effect +import: + +```go +// authbridge/cmd/authbridge/plugins_extra.go +package main + +import _ "github.com/acme/my-plugin" +``` + +No fork of kagenti-extensions required. + +## Step 7 — Test your plugin + +Tests that call `OnRequest` / `OnResponse` directly need to set the +framework attribution fields on `pctx` so `Record` fills `Plugin` and +`Phase` correctly. The `invokeOnRequest` / `invokeOnResponse` helpers +in `plugins_test.go` do this: + +```go +func TestHelloLog_Observes(t *testing.T) { + p := NewHelloLog() + pctx := &pipeline.Context{Direction: pipeline.Inbound, Path: "/x"} + action := invokeOnRequest(p, pctx) + if action.Type != pipeline.Continue { + t.Fatalf("want Continue, got %v", action.Type) + } + if pctx.Extensions.Invocations == nil || + len(pctx.Extensions.Invocations.Inbound) != 1 { + t.Fatalf("expected one invocation, got %+v", pctx.Extensions.Invocations) + } + inv := pctx.Extensions.Invocations.Inbound[0] + if inv.Plugin != "hello-log" || inv.Reason != "request_seen" { + t.Errorf("invocation = %+v", inv) + } +} +``` + +For test isolation (a fake plugin registered in one test should not +leak into another) use `t.Cleanup` with `UnregisterPlugin`: + +```go +func TestScenario(t *testing.T) { + plugins.RegisterPlugin("fake", func() pipeline.Plugin { return &fakePlugin{} }) + t.Cleanup(func() { plugins.UnregisterPlugin("fake") }) + // ... +} +``` + +## Optional interfaces + +Beyond the four required methods, plugins may implement: + +| Interface | When | +|---|---| +| `pipeline.Configurable` | Takes YAML config. See Step 4. | +| `pipeline.Initializer` | Needs one-time setup before serving (load a model, warm a cache, spawn a goroutine). | +| `pipeline.Shutdowner` | Needs graceful cleanup on pod termination (flush audit events, close connections). | +| `pipeline.Readier` | Has deferred initialization that affects `/readyz` (e.g., waiting on a credential file). | + +All optional. A plugin that doesn't implement them is treated as +"always ready, no init, no shutdown." Definitions are in +`authbridge/authlib/pipeline/plugin.go`. + +## Gotchas + +- **Don't hold pctx across goroutines.** The pipeline resets pctx's + framework fields after each plugin returns. Recording an invocation + from a spawned goroutine attributes it to whichever plugin the + pipeline happens to be dispatching at the time — usually garbage. +- **Reads/writes on Extensions slots aren't compile-checked.** The + pipeline's `Capabilities` validation catches "plugin A reads slot X + but no earlier plugin writes X," but typos in string names silently + fall through. Use the constants in `pipeline/extensions.go` when + they exist. +- **DisallowUnknownFields or nothing.** Strict decode in Configure is + not optional. A misspelled key at startup is always a bug; lenient + decode hides it until it misbehaves at 3am. +- **Name collisions panic.** Two plugins registering under the same + name panic at process start. Fix: pick a unique name. + +## Cross-references + +- [`plugin-reference.md`](./plugin-reference.md) — reference detail on config + patterns, the invocation contract, the 5-value action vocabulary, + and the registration rules. +- [`framework-architecture.md`](./framework-architecture.md) — how the pipeline + composes plugins, the Run / RunResponse dispatch order, and the + lifecycle hooks. +- [`pipeline/plugin.go`](../pipeline/plugin.go) — the Plugin interface + and all optional interfaces (Initializer / Shutdowner / Readier / + Configurable).