From 93c436773fd4ef9bf47270301cb1b4e78cbc0496 Mon Sep 17 00:00:00 2001 From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:28:55 -0600 Subject: [PATCH 01/29] :truck: Rename token-budget to session-budget plugin Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> --- .../authlib/listener/forwardproxy/server.go | 2 +- .../e2e_test.go | 20 +++--- .../lifecycle_test.go | 8 +-- .../{tokenbudget => sessionbudget}/plugin.go | 72 +++++++++---------- .../plugin_test.go | 8 +-- .../authbridge-envoy/plugins_sessionbudget.go | 10 +++ .../authbridge-envoy/plugins_tokenbudget.go | 10 --- .../authbridge-proxy/plugins_sessionbudget.go | 10 +++ .../authbridge-proxy/plugins_tokenbudget.go | 10 --- 9 files changed, 75 insertions(+), 75 deletions(-) rename authbridge/authlib/plugins/{tokenbudget => sessionbudget}/e2e_test.go (94%) rename authbridge/authlib/plugins/{tokenbudget => sessionbudget}/lifecycle_test.go (88%) rename authbridge/authlib/plugins/{tokenbudget => sessionbudget}/plugin.go (78%) rename authbridge/authlib/plugins/{tokenbudget => sessionbudget}/plugin_test.go (98%) create mode 100644 authbridge/cmd/authbridge-envoy/plugins_sessionbudget.go delete mode 100644 authbridge/cmd/authbridge-envoy/plugins_tokenbudget.go create mode 100644 authbridge/cmd/authbridge-proxy/plugins_sessionbudget.go delete mode 100644 authbridge/cmd/authbridge-proxy/plugins_tokenbudget.go diff --git a/authbridge/authlib/listener/forwardproxy/server.go b/authbridge/authlib/listener/forwardproxy/server.go index e3d33cc79..66cc19c74 100644 --- a/authbridge/authlib/listener/forwardproxy/server.go +++ b/authbridge/authlib/listener/forwardproxy/server.go @@ -609,7 +609,7 @@ func (s *Server) handleStreamingResponse(w http.ResponseWriter, r *http.Request, defer func() { // Use a detached context for finalization: the client may have // cancelled the request context after reading the full stream, - // but aggregating plugins (inference-parser, token-budget) still + // but aggregating plugins (inference-parser, session-budget) still // need their last=true dispatch to finalize state. finalCtx := context.WithoutCancel(r.Context()) finalAction := s.OutboundPipeline.RunResponseFrame(finalCtx, pctx, nil, true) diff --git a/authbridge/authlib/plugins/tokenbudget/e2e_test.go b/authbridge/authlib/plugins/sessionbudget/e2e_test.go similarity index 94% rename from authbridge/authlib/plugins/tokenbudget/e2e_test.go rename to authbridge/authlib/plugins/sessionbudget/e2e_test.go index 3f4993788..a4a2e5113 100644 --- a/authbridge/authlib/plugins/tokenbudget/e2e_test.go +++ b/authbridge/authlib/plugins/sessionbudget/e2e_test.go @@ -1,4 +1,4 @@ -package tokenbudget +package sessionbudget import ( "context" @@ -16,7 +16,7 @@ import ( "github.com/rossoctl/cortex/authbridge/authlib/session" ) -func newE2EPlugin(t *testing.T, maxTokens int64, store *memStore) *TokenBudget { +func newE2EPlugin(t *testing.T, maxTokens int64, store *memStore) *SessionBudget { t.Helper() p := New() cfg, _ := json.Marshal(config{ @@ -35,11 +35,11 @@ func newE2EPlugin(t *testing.T, maxTokens int64, store *memStore) *TokenBudget { return p } -func respond(p *TokenBudget, sessionID string, tokens int) { +func respond(p *SessionBudget, sessionID string, tokens int) { p.OnResponseFrame(context.Background(), makePctx(sessionID, tokens), nil, true) } -func request(p *TokenBudget, sessionID string) pipeline.Action { +func request(p *SessionBudget, sessionID string) pipeline.Action { pctx := &pipeline.Context{ Direction: pipeline.Outbound, Headers: http.Header{}, @@ -156,9 +156,9 @@ func TestE2E_RefreshRecovery(t *testing.T) { p.store = cs ctx := context.Background() - inner.HashIncr(ctx, "token-budget:s", "tokens", 180) - inner.HashIncr(ctx, "token-budget:s", "calls", 7) - inner.HashSetNX(ctx, "token-budget:s", "started_at", "1700000000") + inner.HashIncr(ctx, "session-budget:s", "tokens", 180) + inner.HashIncr(ctx, "session-budget:s", "calls", 7) + inner.HashSetNX(ctx, "session-budget:s", "started_at", "1700000000") p.mu.Lock() p.cache["s"] = &counters{tokens: 50} @@ -189,9 +189,9 @@ func TestE2E_PodRestart(t *testing.T) { ctx := context.Background() // Pre-seed Redis above the limit (210 > 200). - store.HashIncr(ctx, "token-budget:s", "tokens", 210) - store.HashIncr(ctx, "token-budget:s", "calls", 8) - store.HashSetNX(ctx, "token-budget:s", "started_at", "1700000000") + store.HashIncr(ctx, "session-budget:s", "tokens", 210) + store.HashIncr(ctx, "session-budget:s", "calls", 8) + store.HashSetNX(ctx, "session-budget:s", "started_at", "1700000000") // Cold cache — first request passes (overshoot window). if a := request(p, "s"); a.Type != pipeline.Continue { diff --git a/authbridge/authlib/plugins/tokenbudget/lifecycle_test.go b/authbridge/authlib/plugins/sessionbudget/lifecycle_test.go similarity index 88% rename from authbridge/authlib/plugins/tokenbudget/lifecycle_test.go rename to authbridge/authlib/plugins/sessionbudget/lifecycle_test.go index ee0a129e4..2543474d4 100644 --- a/authbridge/authlib/plugins/tokenbudget/lifecycle_test.go +++ b/authbridge/authlib/plugins/sessionbudget/lifecycle_test.go @@ -1,4 +1,4 @@ -package tokenbudget +package sessionbudget import ( "context" @@ -20,9 +20,9 @@ func TestFullLifecycle_RefreshFromRedis(t *testing.T) { p.store = store ctx := context.Background() - store.HashIncr(ctx, "token-budget:remote-sess", "tokens", 450) - store.HashIncr(ctx, "token-budget:remote-sess", "calls", 10) - store.HashSetNX(ctx, "token-budget:remote-sess", "started_at", "1700000000") + store.HashIncr(ctx, "session-budget:remote-sess", "tokens", 450) + store.HashIncr(ctx, "session-budget:remote-sess", "calls", 10) + store.HashSetNX(ctx, "session-budget:remote-sess", "started_at", "1700000000") // Seed cache so refreshCache picks it up. p.mu.Lock() diff --git a/authbridge/authlib/plugins/tokenbudget/plugin.go b/authbridge/authlib/plugins/sessionbudget/plugin.go similarity index 78% rename from authbridge/authlib/plugins/tokenbudget/plugin.go rename to authbridge/authlib/plugins/sessionbudget/plugin.go index fbc3780c0..b60daf43c 100644 --- a/authbridge/authlib/plugins/tokenbudget/plugin.go +++ b/authbridge/authlib/plugins/sessionbudget/plugin.go @@ -1,8 +1,8 @@ -// Package tokenbudget enforces per-session lifetime budgets on tokens, +// Package sessionbudget enforces per-session lifetime budgets on tokens, // inference calls, and wall-clock duration. Must run before inference-parser // in the declared plugin order (response path is reverse: inference-parser // finalizes counts first, then this plugin reads them). -package tokenbudget +package sessionbudget import ( "context" @@ -35,9 +35,9 @@ type counters struct { startedAt time.Time } -// TokenBudget is the plugin state. Redis provides cross-pod durability; +// SessionBudget is the plugin state. Redis provides cross-pod durability; // the local cache provides zero-I/O enforcement on the request path. -type TokenBudget struct { +type SessionBudget struct { cfg config store storage.Store log *slog.Logger @@ -48,28 +48,28 @@ type TokenBudget struct { stopped chan struct{} } -func New() *TokenBudget { - return &TokenBudget{ +func New() *SessionBudget { + return &SessionBudget{ cache: make(map[string]*counters), stopCh: make(chan struct{}), stopped: make(chan struct{}), - log: slog.Default().With("plugin", "token-budget"), + log: slog.Default().With("plugin", "session-budget"), } } func init() { - plugins.RegisterPlugin("token-budget", func() pipeline.Plugin { return New() }) + plugins.RegisterPlugin("session-budget", func() pipeline.Plugin { return New() }) } -func (p *TokenBudget) Name() string { return "token-budget" } +func (p *SessionBudget) Name() string { return "session-budget" } -func (p *TokenBudget) Capabilities() pipeline.PluginCapabilities { +func (p *SessionBudget) Capabilities() pipeline.PluginCapabilities { return pipeline.PluginCapabilities{ Description: "Enforce per-session token, call, and duration budgets via Redis.", } } -func (p *TokenBudget) Configure(raw json.RawMessage) error { +func (p *SessionBudget) Configure(raw json.RawMessage) error { p.cfg = config{ OnExceed: "deny", SessionTTLSeconds: 7200, @@ -77,33 +77,33 @@ func (p *TokenBudget) Configure(raw json.RawMessage) error { RedisUnavailable: "fail_open", } if err := json.Unmarshal(raw, &p.cfg); err != nil { - return fmt.Errorf("token-budget config: %w", err) + return fmt.Errorf("session-budget config: %w", err) } if p.cfg.RedisURL == "" { - return fmt.Errorf("token-budget: redis_url is required") + return fmt.Errorf("session-budget: redis_url is required") } if p.cfg.MaxTokens <= 0 && p.cfg.MaxCalls <= 0 && p.cfg.MaxDurationSeconds <= 0 { - return fmt.Errorf("token-budget: at least one limit (max_tokens, max_calls, max_duration_seconds) must be > 0") + return fmt.Errorf("session-budget: at least one limit (max_tokens, max_calls, max_duration_seconds) must be > 0") } if p.cfg.OnExceed != "deny" && p.cfg.OnExceed != "observe" { - return fmt.Errorf("token-budget: on_exceed must be \"deny\" or \"observe\" (got %q)", p.cfg.OnExceed) + return fmt.Errorf("session-budget: on_exceed must be \"deny\" or \"observe\" (got %q)", p.cfg.OnExceed) } if d, err := time.ParseDuration(p.cfg.RefreshInterval); err != nil { - return fmt.Errorf("token-budget: invalid refresh_interval %q: %w", p.cfg.RefreshInterval, err) + return fmt.Errorf("session-budget: invalid refresh_interval %q: %w", p.cfg.RefreshInterval, err) } else if d <= 0 { - return fmt.Errorf("token-budget: refresh_interval must be > 0 (got %q)", p.cfg.RefreshInterval) + return fmt.Errorf("session-budget: refresh_interval must be > 0 (got %q)", p.cfg.RefreshInterval) } if p.cfg.RedisUnavailable == "fail_closed" { - return fmt.Errorf("token-budget: redis_unavailable=fail_closed is not yet implemented; use fail_open") + return fmt.Errorf("session-budget: redis_unavailable=fail_closed is not yet implemented; use fail_open") } return nil } -func (p *TokenBudget) Init(_ context.Context) error { +func (p *SessionBudget) Init(_ context.Context) error { // "redis" driver handles both Redis and Valkey (wire-compatible); URL must use redis:// scheme. store, err := storage.Open("redis", p.cfg.RedisURL) if err != nil { - return fmt.Errorf("token-budget: redis connect: %w", err) + return fmt.Errorf("session-budget: redis connect: %w", err) } p.store = store @@ -113,7 +113,7 @@ func (p *TokenBudget) Init(_ context.Context) error { } // In-flight accumulate goroutines get ErrClosed after store.Close — bounded by their 2s ctx. -func (p *TokenBudget) Shutdown(_ context.Context) error { +func (p *SessionBudget) Shutdown(_ context.Context) error { close(p.stopCh) <-p.stopped if p.store != nil { @@ -124,7 +124,7 @@ func (p *TokenBudget) Shutdown(_ context.Context) error { // OnRequest evaluates cached counters against limits and optimistically reserves // a call slot so concurrent requests on the same session see each other. No I/O. -func (p *TokenBudget) OnRequest(_ context.Context, pctx *pipeline.Context) pipeline.Action { +func (p *SessionBudget) OnRequest(_ context.Context, pctx *pipeline.Context) pipeline.Action { sessionID := p.sessionID(pctx) if sessionID == "" { return pipeline.Action{Type: pipeline.Continue} @@ -174,12 +174,12 @@ func (p *TokenBudget) OnRequest(_ context.Context, pctx *pipeline.Context) pipel } // OnResponse is a no-op; see OnResponseFrame. -func (p *TokenBudget) OnResponse(_ context.Context, _ *pipeline.Context) pipeline.Action { +func (p *SessionBudget) OnResponse(_ context.Context, _ *pipeline.Context) pipeline.Action { return pipeline.Action{Type: pipeline.Continue} } // OnResponseFrame accumulates token counts on finalization (last=true). -func (p *TokenBudget) OnResponseFrame(_ context.Context, pctx *pipeline.Context, _ []byte, last bool) pipeline.Action { +func (p *SessionBudget) OnResponseFrame(_ context.Context, pctx *pipeline.Context, _ []byte, last bool) pipeline.Action { if !last { return pipeline.Action{Type: pipeline.Continue} } @@ -212,7 +212,7 @@ func (p *TokenBudget) OnResponseFrame(_ context.Context, pctx *pipeline.Context, return pipeline.Action{Type: pipeline.Continue} } -func (p *TokenBudget) evaluate(c *counters) string { +func (p *SessionBudget) evaluate(c *counters) string { if p.cfg.MaxTokens > 0 && c.tokens >= p.cfg.MaxTokens { return fmt.Sprintf("token limit reached: %d/%d", c.tokens, p.cfg.MaxTokens) } @@ -229,7 +229,7 @@ func (p *TokenBudget) evaluate(c *counters) string { } // accumulate writes counters to Redis. On failure, writes are dropped (fail-open). -func (p *TokenBudget) accumulate(sessionID string, tokens int64) { +func (p *SessionBudget) accumulate(sessionID string, tokens int64) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() @@ -252,7 +252,7 @@ func (p *TokenBudget) accumulate(sessionID string, tokens int64) { } } -func (p *TokenBudget) refreshLoop(interval time.Duration) { +func (p *SessionBudget) refreshLoop(interval time.Duration) { defer close(p.stopped) ticker := time.NewTicker(interval) defer ticker.Stop() @@ -268,7 +268,7 @@ func (p *TokenBudget) refreshLoop(interval time.Duration) { } // refreshCache replaces local counters with authoritative Redis values. -func (p *TokenBudget) refreshCache() { +func (p *SessionBudget) refreshCache() { p.mu.RLock() keys := make([]string, 0, len(p.cache)) for k := range p.cache { @@ -319,21 +319,21 @@ func (p *TokenBudget) refreshCache() { } } -func (p *TokenBudget) sessionID(pctx *pipeline.Context) string { +func (p *SessionBudget) sessionID(pctx *pipeline.Context) string { if pctx.Session != nil && pctx.Session.ID != "" { return pctx.Session.ID } return "" } -func (p *TokenBudget) redisKey(sessionID string) string { - return "token-budget:" + sessionID +func (p *SessionBudget) redisKey(sessionID string) string { + return "session-budget:" + sessionID } var ( - _ pipeline.Plugin = (*TokenBudget)(nil) - _ pipeline.Configurable = (*TokenBudget)(nil) - _ pipeline.Initializer = (*TokenBudget)(nil) - _ pipeline.Shutdowner = (*TokenBudget)(nil) - _ pipeline.StreamingResponder = (*TokenBudget)(nil) + _ pipeline.Plugin = (*SessionBudget)(nil) + _ pipeline.Configurable = (*SessionBudget)(nil) + _ pipeline.Initializer = (*SessionBudget)(nil) + _ pipeline.Shutdowner = (*SessionBudget)(nil) + _ pipeline.StreamingResponder = (*SessionBudget)(nil) ) diff --git a/authbridge/authlib/plugins/tokenbudget/plugin_test.go b/authbridge/authlib/plugins/sessionbudget/plugin_test.go similarity index 98% rename from authbridge/authlib/plugins/tokenbudget/plugin_test.go rename to authbridge/authlib/plugins/sessionbudget/plugin_test.go index d11e63418..427f53d56 100644 --- a/authbridge/authlib/plugins/tokenbudget/plugin_test.go +++ b/authbridge/authlib/plugins/sessionbudget/plugin_test.go @@ -1,4 +1,4 @@ -package tokenbudget +package sessionbudget import ( "context" @@ -128,7 +128,7 @@ func init() { }) } -func newTestPlugin(maxTokens, maxCalls, maxDuration int64) *TokenBudget { +func newTestPlugin(maxTokens, maxCalls, maxDuration int64) *SessionBudget { p := New() cfg := fmt.Sprintf(`{ "redis_url": "mem://test", @@ -253,7 +253,7 @@ func TestAccumulate_WritesToStore(t *testing.T) { p.accumulate("sess-1", 100) - fields, _ := store.HashGet(context.Background(), "token-budget:sess-1") + fields, _ := store.HashGet(context.Background(), "session-budget:sess-1") if fields["tokens"] != "100" { t.Errorf("tokens in store = %q, want 100", fields["tokens"]) } @@ -272,7 +272,7 @@ func TestAccumulate_ZeroTokens(t *testing.T) { p.accumulate("sess-1", 0) - fields, _ := store.HashGet(context.Background(), "token-budget:sess-1") + fields, _ := store.HashGet(context.Background(), "session-budget:sess-1") if fields["tokens"] != "" { t.Errorf("tokens in store = %q, want empty (no HINCRBY for 0)", fields["tokens"]) } diff --git a/authbridge/cmd/authbridge-envoy/plugins_sessionbudget.go b/authbridge/cmd/authbridge-envoy/plugins_sessionbudget.go new file mode 100644 index 000000000..bd3dd9c4b --- /dev/null +++ b/authbridge/cmd/authbridge-envoy/plugins_sessionbudget.go @@ -0,0 +1,10 @@ +//go:build include_plugin_sessionbudget + +// session-budget is opt-IN: it pulls the storage/redis module (go-redis) +// into the binary. Build with -tags include_plugin_sessionbudget to link it. +package main + +import ( + _ "github.com/rossoctl/cortex/authbridge/authlib/plugins/sessionbudget" + _ "github.com/rossoctl/cortex/authbridge/storage/redis" +) diff --git a/authbridge/cmd/authbridge-envoy/plugins_tokenbudget.go b/authbridge/cmd/authbridge-envoy/plugins_tokenbudget.go deleted file mode 100644 index 93a74754b..000000000 --- a/authbridge/cmd/authbridge-envoy/plugins_tokenbudget.go +++ /dev/null @@ -1,10 +0,0 @@ -//go:build include_plugin_tokenbudget - -// token-budget is opt-IN: it pulls the storage/redis module (go-redis) -// into the binary. Build with -tags include_plugin_tokenbudget to link it. -package main - -import ( - _ "github.com/rossoctl/cortex/authbridge/authlib/plugins/tokenbudget" - _ "github.com/rossoctl/cortex/authbridge/storage/redis" -) diff --git a/authbridge/cmd/authbridge-proxy/plugins_sessionbudget.go b/authbridge/cmd/authbridge-proxy/plugins_sessionbudget.go new file mode 100644 index 000000000..bd3dd9c4b --- /dev/null +++ b/authbridge/cmd/authbridge-proxy/plugins_sessionbudget.go @@ -0,0 +1,10 @@ +//go:build include_plugin_sessionbudget + +// session-budget is opt-IN: it pulls the storage/redis module (go-redis) +// into the binary. Build with -tags include_plugin_sessionbudget to link it. +package main + +import ( + _ "github.com/rossoctl/cortex/authbridge/authlib/plugins/sessionbudget" + _ "github.com/rossoctl/cortex/authbridge/storage/redis" +) diff --git a/authbridge/cmd/authbridge-proxy/plugins_tokenbudget.go b/authbridge/cmd/authbridge-proxy/plugins_tokenbudget.go deleted file mode 100644 index 93a74754b..000000000 --- a/authbridge/cmd/authbridge-proxy/plugins_tokenbudget.go +++ /dev/null @@ -1,10 +0,0 @@ -//go:build include_plugin_tokenbudget - -// token-budget is opt-IN: it pulls the storage/redis module (go-redis) -// into the binary. Build with -tags include_plugin_tokenbudget to link it. -package main - -import ( - _ "github.com/rossoctl/cortex/authbridge/authlib/plugins/tokenbudget" - _ "github.com/rossoctl/cortex/authbridge/storage/redis" -) From 24bb6cd405c668b68a98ab61630ef1ada9444aba Mon Sep 17 00:00:00 2001 From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:52:46 -0600 Subject: [PATCH 02/29] :truck::memo: Update session budget documentation Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> --- ...get-plugin.md => session-budget-plugin.md} | 93 ++++++++++++++++--- 1 file changed, 79 insertions(+), 14 deletions(-) rename authbridge/docs/{token-budget-plugin.md => session-budget-plugin.md} (59%) diff --git a/authbridge/docs/token-budget-plugin.md b/authbridge/docs/session-budget-plugin.md similarity index 59% rename from authbridge/docs/token-budget-plugin.md rename to authbridge/docs/session-budget-plugin.md index d2bbaab2f..d5d4181bd 100644 --- a/authbridge/docs/token-budget-plugin.md +++ b/authbridge/docs/session-budget-plugin.md @@ -1,32 +1,37 @@ -# token-budget Plugin +# session-budget Plugin Enforces per-session lifetime budgets on tokens, inference calls, and -wall-clock duration. Supports `observe` (shadow — log without blocking) and -`deny` (return 403) modes. Uses Redis for cross-pod durable counters; -evaluates limits from a local cache with zero I/O on the hot path. +wall-clock duration. Supports three `on_exceed` modes: + +- `deny` — return 403 (default) +- `observe` — shadow mode: log without blocking +- `pause` — HITL: fire a webhook for human/system approval before continuing + +Uses Redis for cross-pod durable counters; evaluates limits from a local +cache with zero I/O on the hot path. A "session" maps to the AuthBridge session ID (typically one A2A conversation or agent task invocation). ## Build Tag -This plugin is **opt-IN**. Build with `-tags include_plugin_tokenbudget` +This plugin is **opt-IN**. Build with `-tags include_plugin_sessionbudget` to include it (and its `storage/redis` dependency) in the binary: ```bash cd authbridge docker build -f cmd/authbridge-proxy/Dockerfile \ - --build-arg GO_BUILD_TAGS="include_plugin_tokenbudget" \ + --build-arg GO_BUILD_TAGS="include_plugin_sessionbudget" \ -t authbridge:latest . ``` -Without the tag, neither token-budget nor go-redis are linked. +Without the tag, neither session-budget nor go-redis are linked. The same build tag works for the envoy-sidecar image: ```bash docker build -f cmd/authbridge-envoy/Dockerfile \ - --build-arg GO_BUILD_TAGS="include_plugin_tokenbudget" \ + --build-arg GO_BUILD_TAGS="include_plugin_sessionbudget" \ -t authbridge-envoy:latest . ``` @@ -38,7 +43,7 @@ pipeline: plugins: - name: token-exchange config: { ... } - - name: token-budget + - name: session-budget config: redis_url: "redis://valkey.infra.svc:6379" max_tokens: 50000 @@ -53,7 +58,11 @@ pipeline: | `max_tokens` | no | 0 | Cumulative token ceiling per session (0 = no limit) | | `max_calls` | no | 0 | Max inference calls per session (0 = no limit) | | `max_duration_seconds` | no | 0 | Wall-clock session lifetime in seconds (0 = no limit) | -| `on_exceed` | no | "deny" | `deny` (block with 403) or `observe` (shadow — log but continue) | +| `on_exceed` | no | "deny" | `deny`, `observe`, or `pause` | +| `pause_webhook` | no | — | URL to POST for approval (required when `on_exceed=pause`) | +| `pause_timeout` | no | "30s" | How long to wait for webhook response | +| `pause_timeout_action` | no | "deny" | Action on webhook timeout/error: `deny` or `allow` | +| `pause_grace_period` | no | "5m" | After approval, suppress further webhooks for this duration | | `session_ttl_seconds` | no | 7200 | Redis key TTL; should be >= `max_duration_seconds` | | `refresh_interval` | no | "5s" | How often to sync local cache from Redis | | `redis_unavailable` | no | "fail_open" | Only `fail_open` supported (stale cache retained on failure). `fail_closed` reserved. | @@ -63,7 +72,7 @@ At least one of `max_tokens`, `max_calls`, or `max_duration_seconds` must be > 0 **Local Redis/Valkey** for development: `docker run -d --name valkey -p 6379:6379 valkey/valkey:latest`. Use `redis://localhost:6379` or `redis://host.docker.internal:6379` (container mode). -## Shadow Mode +## Shadow Mode (observe) Set `on_exceed: "observe"` to run the plugin in shadow mode. The plugin still accumulates counters and evaluates limits, but instead of blocking @@ -76,11 +85,62 @@ Rollout workflow: 3. Adjust `max_tokens` / `max_calls` / `max_duration_seconds` based on observed patterns 4. Flip to `on_exceed: "deny"` when confident in the thresholds +## Pause Mode (HITL) + +Set `on_exceed: "pause"` to enable human-in-the-loop approval. When budget +is exceeded, the plugin fires a synchronous HTTP POST to `pause_webhook` +and blocks the request until a response arrives (up to `pause_timeout`). + +### Webhook request body (POST) + +```json +{ + "session_id": "abc-123", + "reason": "call limit reached: 50/50", + "spent_tokens": 48200, + "spent_calls": 50, + "token_limit": 100000, + "call_limit": 50, + "duration_seconds": 1205, + "duration_limit": 1800 +} +``` + +### Webhook expected response + +```json +{"action": "approve"} +``` +or +```json +{"action": "deny", "reason": "operator rejected"} +``` + +### Behavior on approval + +After `{"action": "approve"}`, the request continues and subsequent requests +within `pause_grace_period` (default 5m) pass without re-invoking the webhook. +This prevents per-request webhook spam once a session is approved to continue. + +### Behavior on timeout/error + +If the webhook is unreachable, returns non-200, returns invalid JSON, or +exceeds `pause_timeout`, the plugin falls back to `pause_timeout_action`: +- `"deny"` (default) — reject with 403 +- `"allow"` — continue the pipeline + +### Design notes + +- The request goroutine blocks during the webhook call (same pattern as IBAC's LLM judge) +- Client disconnect cancels the request context, which cancels the webhook call +- The grace period is pod-local (in-memory cache); worst case after pod restart is one extra webhook call +- Concurrent requests from the same session may both fire webhooks (acceptable for v1) + ## Pipeline Position Must be declared **before** `inference-parser` in the outbound plugin list. The response path runs in reverse order, so inference-parser finalizes token -counts first, then token-budget reads them. Both plugins implement +counts first, then session-budget reads them. Both plugins implement `StreamingResponder` so they work with streaming SSE responses (Ollama, LiteLLM, OpenAI) and buffered JSON responses alike. @@ -106,12 +166,16 @@ LiteLLM, OpenAI) and buffered JSON responses alike. ## Redis Key Schema ```text -token-budget: (Hash, TTL = session_ttl_seconds) +session-budget: (Hash, TTL = session_ttl_seconds) tokens int cumulative TotalTokens calls int inference call count started_at unix first-call timestamp (set-if-not-exists) ``` +**Migration note:** The Redis key prefix changed from `token-budget:` to +`session-budget:`. Existing keys from prior deployments will be orphaned +and expire within `session_ttl_seconds`. + ## Failure Modes | Scenario | Behavior | @@ -120,6 +184,7 @@ token-budget: (Hash, TTL = session_ttl_seconds) | Redis fails mid-session | Local cache continues enforcing; writes dropped silently | | Pod restarts | First request passes (cold cache); refresh picks up Redis counters within one interval | | Provider returns no usage data | `max_tokens` not enforced; `max_calls` and `max_duration_seconds` still work | +| Pause webhook unreachable | Falls back to `pause_timeout_action` (default: deny) | **Fail-open guarantee:** The plugin never blocks requests due to its own infrastructure failures. Redis unavailability degrades enforcement (local cache only, no cross-pod consistency) but never causes false denials. @@ -135,7 +200,7 @@ and work fully. ```bash cd authbridge/authlib -go test ./plugins/tokenbudget/... -v -count=1 +go test ./plugins/sessionbudget/... -v -count=1 ``` No external dependencies — tests use in-memory stores. From b13a6e935406e391efa9e5f81a95002e9ce11e67 Mon Sep 17 00:00:00 2001 From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:54:05 -0600 Subject: [PATCH 03/29] :sparkles: Session budget HITL pause mode Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> --- .../authlib/plugins/sessionbudget/plugin.go | 179 +++++++++++++++--- 1 file changed, 155 insertions(+), 24 deletions(-) diff --git a/authbridge/authlib/plugins/sessionbudget/plugin.go b/authbridge/authlib/plugins/sessionbudget/plugin.go index b60daf43c..d4cfe238c 100644 --- a/authbridge/authlib/plugins/sessionbudget/plugin.go +++ b/authbridge/authlib/plugins/sessionbudget/plugin.go @@ -5,10 +5,13 @@ package sessionbudget import ( + "bytes" "context" "encoding/json" "fmt" + "io" "log/slog" + "net/http" "strconv" "sync" "time" @@ -23,24 +26,31 @@ type config struct { MaxTokens int64 `json:"max_tokens" description:"Cumulative token ceiling per session. 0 = no limit."` MaxCalls int64 `json:"max_calls" description:"Max inference calls per session. 0 = no limit."` MaxDurationSeconds int64 `json:"max_duration_seconds" description:"Wall-clock session lifetime in seconds. 0 = no limit."` - OnExceed string `json:"on_exceed" description:"Action on breach: deny (block) or observe (shadow — log but continue)." default:"deny" enum:"deny,observe"` + OnExceed string `json:"on_exceed" description:"Action on breach: deny, observe (shadow), or pause (HITL webhook approval)." default:"deny" enum:"deny,observe,pause"` + PauseWebhook string `json:"pause_webhook" description:"URL to POST for approval when on_exceed=pause. Required when on_exceed=pause."` + PauseTimeout string `json:"pause_timeout" description:"How long to wait for webhook response." default:"30s"` + PauseTimeoutAction string `json:"pause_timeout_action" description:"Action on webhook timeout/error: deny or allow." default:"deny" enum:"deny,allow"` + PauseGracePeriod string `json:"pause_grace_period" description:"After approval, suppress further webhooks for this duration." default:"5m"` SessionTTLSeconds int `json:"session_ttl_seconds" description:"Redis key TTL; should be >= max_duration_seconds." default:"7200"` RefreshInterval string `json:"refresh_interval" description:"How often to sync local cache from Redis." default:"5s"` RedisUnavailable string `json:"redis_unavailable" description:"Behavior when Redis is unreachable. Only fail_open is supported; fail_closed is reserved." default:"fail_open"` } type counters struct { - tokens int64 - calls int64 - startedAt time.Time + tokens int64 + calls int64 + startedAt time.Time + lastApprovedAt time.Time } // SessionBudget is the plugin state. Redis provides cross-pod durability; // the local cache provides zero-I/O enforcement on the request path. type SessionBudget struct { - cfg config - store storage.Store - log *slog.Logger + cfg config + store storage.Store + log *slog.Logger + httpClient *http.Client + gracePeriod time.Duration mu sync.RWMutex cache map[string]*counters @@ -85,8 +95,35 @@ func (p *SessionBudget) Configure(raw json.RawMessage) error { if p.cfg.MaxTokens <= 0 && p.cfg.MaxCalls <= 0 && p.cfg.MaxDurationSeconds <= 0 { return fmt.Errorf("session-budget: at least one limit (max_tokens, max_calls, max_duration_seconds) must be > 0") } - if p.cfg.OnExceed != "deny" && p.cfg.OnExceed != "observe" { - return fmt.Errorf("session-budget: on_exceed must be \"deny\" or \"observe\" (got %q)", p.cfg.OnExceed) + switch p.cfg.OnExceed { + case "deny", "observe", "pause": + default: + return fmt.Errorf("session-budget: on_exceed must be \"deny\", \"observe\", or \"pause\" (got %q)", p.cfg.OnExceed) + } + if p.cfg.OnExceed == "pause" { + if p.cfg.PauseWebhook == "" { + return fmt.Errorf("session-budget: pause_webhook is required when on_exceed=\"pause\"") + } + if p.cfg.PauseTimeout == "" { + p.cfg.PauseTimeout = "30s" + } + if _, err := time.ParseDuration(p.cfg.PauseTimeout); err != nil { + return fmt.Errorf("session-budget: invalid pause_timeout %q: %w", p.cfg.PauseTimeout, err) + } + if p.cfg.PauseTimeoutAction == "" { + p.cfg.PauseTimeoutAction = "deny" + } + if p.cfg.PauseTimeoutAction != "deny" && p.cfg.PauseTimeoutAction != "allow" { + return fmt.Errorf("session-budget: pause_timeout_action must be \"deny\" or \"allow\" (got %q)", p.cfg.PauseTimeoutAction) + } + if p.cfg.PauseGracePeriod == "" { + p.cfg.PauseGracePeriod = "5m" + } + if d, err := time.ParseDuration(p.cfg.PauseGracePeriod); err != nil { + return fmt.Errorf("session-budget: invalid pause_grace_period %q: %w", p.cfg.PauseGracePeriod, err) + } else { + p.gracePeriod = d + } } if d, err := time.ParseDuration(p.cfg.RefreshInterval); err != nil { return fmt.Errorf("session-budget: invalid refresh_interval %q: %w", p.cfg.RefreshInterval, err) @@ -107,6 +144,10 @@ func (p *SessionBudget) Init(_ context.Context) error { } p.store = store + if p.cfg.OnExceed == "pause" && p.httpClient == nil { + p.httpClient = &http.Client{Timeout: 0} + } + interval, _ := time.ParseDuration(p.cfg.RefreshInterval) go p.refreshLoop(interval) return nil @@ -124,7 +165,7 @@ func (p *SessionBudget) Shutdown(_ context.Context) error { // OnRequest evaluates cached counters against limits and optimistically reserves // a call slot so concurrent requests on the same session see each other. No I/O. -func (p *SessionBudget) OnRequest(_ context.Context, pctx *pipeline.Context) pipeline.Action { +func (p *SessionBudget) OnRequest(ctx context.Context, pctx *pipeline.Context) pipeline.Action { sessionID := p.sessionID(pctx) if sessionID == "" { return pipeline.Action{Type: pipeline.Continue} @@ -141,8 +182,8 @@ func (p *SessionBudget) OnRequest(_ context.Context, pctx *pipeline.Context) pip } snap := *c if reason := p.evaluate(&snap); reason != "" { - if p.cfg.OnExceed == "observe" { - // Still reserve a call — the request will proceed in shadow mode. + switch p.cfg.OnExceed { + case "observe": c.calls++ p.mu.Unlock() pctx.Observe("shadow_budget_exceeded") @@ -152,19 +193,34 @@ func (p *SessionBudget) OnRequest(_ context.Context, pctx *pipeline.Context) pip "tokens", snap.tokens, "calls", snap.calls) return pipeline.Action{Type: pipeline.Continue} + + case "pause": + if p.gracePeriod > 0 && !c.lastApprovedAt.IsZero() && time.Since(c.lastApprovedAt) < p.gracePeriod { + c.calls++ + p.mu.Unlock() + return pipeline.Action{Type: pipeline.Continue} + } + c.calls++ + p.mu.Unlock() + p.log.Info("budget exceeded, requesting approval", + "session", sessionID, + "reason", reason) + if p.callPauseWebhook(ctx, sessionID, reason, &snap) { + p.mu.Lock() + if cc, ok := p.cache[sessionID]; ok { + cc.lastApprovedAt = time.Now() + } + p.mu.Unlock() + return pipeline.Action{Type: pipeline.Continue} + } + details := p.buildDetails(&snap) + return pipeline.DenyWithDetails("budget.exceeded", reason+" (approval denied)", details) + + default: // "deny" + p.mu.Unlock() + details := p.buildDetails(&snap) + return pipeline.DenyWithDetails("budget.exceeded", reason, details) } - p.mu.Unlock() - details := map[string]any{ - "spent_tokens": snap.tokens, - "spent_calls": snap.calls, - "token_limit": p.cfg.MaxTokens, - "call_limit": p.cfg.MaxCalls, - } - if p.cfg.MaxDurationSeconds > 0 && !snap.startedAt.IsZero() { - details["duration_seconds"] = int64(time.Since(snap.startedAt).Seconds()) - details["duration_limit"] = p.cfg.MaxDurationSeconds - } - return pipeline.DenyWithDetails("budget.exceeded", reason, details) } // Optimistically reserve a call slot so concurrent requests see it. c.calls++ @@ -212,6 +268,81 @@ func (p *SessionBudget) OnResponseFrame(_ context.Context, pctx *pipeline.Contex return pipeline.Action{Type: pipeline.Continue} } +func (p *SessionBudget) buildDetails(snap *counters) map[string]any { + details := map[string]any{ + "spent_tokens": snap.tokens, + "spent_calls": snap.calls, + "token_limit": p.cfg.MaxTokens, + "call_limit": p.cfg.MaxCalls, + } + if p.cfg.MaxDurationSeconds > 0 && !snap.startedAt.IsZero() { + details["duration_seconds"] = int64(time.Since(snap.startedAt).Seconds()) + details["duration_limit"] = p.cfg.MaxDurationSeconds + } + return details +} + +type pauseRequest struct { + SessionID string `json:"session_id"` + Reason string `json:"reason"` + SpentTokens int64 `json:"spent_tokens"` + SpentCalls int64 `json:"spent_calls"` + TokenLimit int64 `json:"token_limit"` + CallLimit int64 `json:"call_limit"` + DurationSeconds int64 `json:"duration_seconds,omitempty"` + DurationLimit int64 `json:"duration_limit,omitempty"` +} + +type pauseResponse struct { + Action string `json:"action"` +} + +func (p *SessionBudget) callPauseWebhook(ctx context.Context, sessionID, reason string, snap *counters) bool { + timeout, _ := time.ParseDuration(p.cfg.PauseTimeout) + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + body := pauseRequest{ + SessionID: sessionID, + Reason: reason, + SpentTokens: snap.tokens, + SpentCalls: snap.calls, + TokenLimit: p.cfg.MaxTokens, + CallLimit: p.cfg.MaxCalls, + } + if p.cfg.MaxDurationSeconds > 0 && !snap.startedAt.IsZero() { + body.DurationSeconds = int64(time.Since(snap.startedAt).Seconds()) + body.DurationLimit = p.cfg.MaxDurationSeconds + } + + payload, _ := json.Marshal(body) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.cfg.PauseWebhook, bytes.NewReader(payload)) + if err != nil { + p.log.Warn("pause webhook request build failed", "session", sessionID, "err", err) + return p.cfg.PauseTimeoutAction == "allow" + } + req.Header.Set("Content-Type", "application/json") + + resp, err := p.httpClient.Do(req) + if err != nil { + p.log.Warn("pause webhook call failed", "session", sessionID, "err", err) + return p.cfg.PauseTimeoutAction == "allow" + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + p.log.Warn("pause webhook non-200", "session", sessionID, "status", resp.StatusCode) + return p.cfg.PauseTimeoutAction == "allow" + } + + var result pauseResponse + if err := json.NewDecoder(io.LimitReader(resp.Body, 4096)).Decode(&result); err != nil { + p.log.Warn("pause webhook response decode failed", "session", sessionID, "err", err) + return p.cfg.PauseTimeoutAction == "allow" + } + return result.Action == "approve" +} + func (p *SessionBudget) evaluate(c *counters) string { if p.cfg.MaxTokens > 0 && c.tokens >= p.cfg.MaxTokens { return fmt.Sprintf("token limit reached: %d/%d", c.tokens, p.cfg.MaxTokens) From 426fcf246de0323bb2a585555be126ee4efca9b2 Mon Sep 17 00:00:00 2001 From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:54:34 -0600 Subject: [PATCH 04/29] :white_check_mark: Update tests for session budget pause mode Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> --- .../authlib/plugins/sessionbudget/e2e_test.go | 93 ++++++- .../plugins/sessionbudget/plugin_test.go | 238 ++++++++++++++++++ 2 files changed, 330 insertions(+), 1 deletion(-) diff --git a/authbridge/authlib/plugins/sessionbudget/e2e_test.go b/authbridge/authlib/plugins/sessionbudget/e2e_test.go index a4a2e5113..e5eb4d974 100644 --- a/authbridge/authlib/plugins/sessionbudget/e2e_test.go +++ b/authbridge/authlib/plugins/sessionbudget/e2e_test.go @@ -48,7 +48,7 @@ func request(p *SessionBudget, sessionID string) pipeline.Action { return p.OnRequest(context.Background(), pctx) } -// TestE2E_HTTPRoundTrip wires token-budget into a real forward proxy. +// TestE2E_HTTPRoundTrip wires session-budget into a real forward proxy. // Under-budget requests reach the backend; the proxy is functional. func TestE2E_HTTPRoundTrip(t *testing.T) { store := newMemStore() @@ -294,3 +294,94 @@ func TestE2E_ShadowMode(t *testing.T) { t.Fatalf("shadow mode (2nd request): expected Continue, got %v", action.Type) } } + +// TestE2E_PauseMode verifies the full lifecycle: accumulate past limit, +// webhook approves, request passes through. +func TestE2E_PauseMode(t *testing.T) { + approveServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req map[string]any + json.NewDecoder(r.Body).Decode(&req) + if req["session_id"] != "sess" { + t.Errorf("webhook got session_id=%v, want sess", req["session_id"]) + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"action":"approve"}`)) + })) + defer approveServer.Close() + + p := New() + cfg, _ := json.Marshal(config{ + RedisURL: "mem://test", + MaxCalls: 3, + OnExceed: "pause", + PauseWebhook: approveServer.URL, + PauseTimeout: "5s", + PauseTimeoutAction: "deny", + RefreshInterval: "30ms", + RedisUnavailable: "fail_open", + }) + if err := p.Configure(cfg); err != nil { + t.Fatalf("Configure: %v", err) + } + p.store = newMemStore() + p.httpClient = &http.Client{} + go p.refreshLoop(30 * time.Millisecond) + t.Cleanup(func() { close(p.stopCh); <-p.stopped }) + + // Seed cache at the limit. + p.mu.Lock() + p.cache["sess"] = &counters{tokens: 100, calls: 3, startedAt: time.Now()} + p.mu.Unlock() + + // Next request exceeds limit — webhook approves. + action := request(p, "sess") + if action.Type != pipeline.Continue { + t.Fatalf("pause mode (approved): expected Continue, got %v", action.Type) + } +} + +// TestE2E_PauseModeDeny verifies webhook denial produces a 403. +func TestE2E_PauseModeDeny(t *testing.T) { + denyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"action":"deny"}`)) + })) + defer denyServer.Close() + + p := New() + cfg, _ := json.Marshal(config{ + RedisURL: "mem://test", + MaxCalls: 3, + OnExceed: "pause", + PauseWebhook: denyServer.URL, + PauseTimeout: "5s", + PauseTimeoutAction: "deny", + RefreshInterval: "30ms", + RedisUnavailable: "fail_open", + }) + if err := p.Configure(cfg); err != nil { + t.Fatalf("Configure: %v", err) + } + p.store = newMemStore() + p.httpClient = &http.Client{} + go p.refreshLoop(30 * time.Millisecond) + t.Cleanup(func() { close(p.stopCh); <-p.stopped }) + + p.mu.Lock() + p.cache["sess"] = &counters{tokens: 100, calls: 3, startedAt: time.Now()} + p.mu.Unlock() + + action := request(p, "sess") + if action.Type != pipeline.Reject { + t.Fatalf("pause mode (denied): expected Reject, got %v", action.Type) + } + status, _, body := action.Violation.Render() + if status != http.StatusForbidden { + t.Errorf("status = %d, want 403", status) + } + var parsed map[string]any + json.Unmarshal(body, &parsed) + if parsed["error"] != "budget.exceeded" { + t.Errorf("error = %v, want budget.exceeded", parsed["error"]) + } +} diff --git a/authbridge/authlib/plugins/sessionbudget/plugin_test.go b/authbridge/authlib/plugins/sessionbudget/plugin_test.go index 427f53d56..8e48e5a4e 100644 --- a/authbridge/authlib/plugins/sessionbudget/plugin_test.go +++ b/authbridge/authlib/plugins/sessionbudget/plugin_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http" + "net/http/httptest" "sync" "testing" "time" @@ -322,6 +323,10 @@ func TestConfigure_Validation(t *testing.T) { {"unparseable refresh_interval", `{"redis_url":"redis://localhost","max_tokens":100,"refresh_interval":"abc"}`, true}, {"fail_closed rejected", `{"redis_url":"redis://localhost","max_tokens":100,"redis_unavailable":"fail_closed"}`, true}, {"invalid on_exceed", `{"redis_url":"redis://localhost","max_tokens":100,"on_exceed":"block"}`, true}, + {"pause valid", `{"redis_url":"redis://localhost","max_calls":10,"on_exceed":"pause","pause_webhook":"http://localhost:9999/approve"}`, false}, + {"pause missing webhook", `{"redis_url":"redis://localhost","max_calls":10,"on_exceed":"pause"}`, true}, + {"pause invalid timeout", `{"redis_url":"redis://localhost","max_calls":10,"on_exceed":"pause","pause_webhook":"http://x","pause_timeout":"nope"}`, true}, + {"pause invalid timeout_action", `{"redis_url":"redis://localhost","max_calls":10,"on_exceed":"pause","pause_webhook":"http://x","pause_timeout_action":"maybe"}`, true}, } for _, tt := range tests { @@ -450,6 +455,239 @@ func TestOnRequest_ConcurrentCallLimit(t *testing.T) { } } +func newPausePlugin(t *testing.T, maxCalls int64, webhookURL, timeoutAction string) *SessionBudget { + t.Helper() + p := New() + cfg := fmt.Sprintf(`{ + "redis_url": "mem://test", + "max_calls": %d, + "on_exceed": "pause", + "pause_webhook": %q, + "pause_timeout": "2s", + "pause_timeout_action": %q, + "refresh_interval": "100ms" + }`, maxCalls, webhookURL, timeoutAction) + if err := p.Configure(json.RawMessage(cfg)); err != nil { + t.Fatalf("Configure: %v", err) + } + p.store = newMemStore() + p.httpClient = &http.Client{Timeout: 0} + return p +} + +func TestOnRequest_PauseApproved(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"action":"approve"}`)) + })) + defer srv.Close() + + p := newPausePlugin(t, 3, srv.URL, "deny") + p.mu.Lock() + p.cache["sess-1"] = &counters{tokens: 0, calls: 3, startedAt: time.Now()} + p.mu.Unlock() + + action := p.OnRequest(context.Background(), makePctx("sess-1", 0)) + if action.Type != pipeline.Continue { + t.Fatalf("expected Continue after approval, got %v", action.Type) + } +} + +func TestOnRequest_PauseDenied(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"action":"deny"}`)) + })) + defer srv.Close() + + p := newPausePlugin(t, 3, srv.URL, "deny") + p.mu.Lock() + p.cache["sess-1"] = &counters{tokens: 0, calls: 3, startedAt: time.Now()} + p.mu.Unlock() + + action := p.OnRequest(context.Background(), makePctx("sess-1", 0)) + if action.Type != pipeline.Reject { + t.Fatalf("expected Reject after denial, got %v", action.Type) + } +} + +func TestOnRequest_PauseTimeout(t *testing.T) { + done := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-done + })) + defer srv.Close() + + p := newPausePlugin(t, 3, srv.URL, "deny") + p.mu.Lock() + p.cache["sess-1"] = &counters{tokens: 0, calls: 3, startedAt: time.Now()} + p.mu.Unlock() + + action := p.OnRequest(context.Background(), makePctx("sess-1", 0)) + close(done) + if action.Type != pipeline.Reject { + t.Fatalf("expected Reject on timeout (pause_timeout_action=deny), got %v", action.Type) + } +} + +func TestOnRequest_PauseTimeoutAllow(t *testing.T) { + done := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-done + })) + defer srv.Close() + + p := newPausePlugin(t, 3, srv.URL, "allow") + p.mu.Lock() + p.cache["sess-1"] = &counters{tokens: 0, calls: 3, startedAt: time.Now()} + p.mu.Unlock() + + action := p.OnRequest(context.Background(), makePctx("sess-1", 0)) + close(done) + if action.Type != pipeline.Continue { + t.Fatalf("expected Continue on timeout (pause_timeout_action=allow), got %v", action.Type) + } +} + +func TestOnRequest_PauseWebhookNon200(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + p := newPausePlugin(t, 3, srv.URL, "deny") + p.mu.Lock() + p.cache["sess-1"] = &counters{tokens: 0, calls: 3, startedAt: time.Now()} + p.mu.Unlock() + + action := p.OnRequest(context.Background(), makePctx("sess-1", 0)) + if action.Type != pipeline.Reject { + t.Fatalf("expected Reject on non-200 webhook (pause_timeout_action=deny), got %v", action.Type) + } +} + +func TestOnRequest_PauseWebhookBadJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`not json`)) + })) + defer srv.Close() + + p := newPausePlugin(t, 3, srv.URL, "deny") + p.mu.Lock() + p.cache["sess-1"] = &counters{tokens: 0, calls: 3, startedAt: time.Now()} + p.mu.Unlock() + + action := p.OnRequest(context.Background(), makePctx("sess-1", 0)) + if action.Type != pipeline.Reject { + t.Fatalf("expected Reject on bad JSON (pause_timeout_action=deny), got %v", action.Type) + } +} + +func TestOnRequest_PauseWebhookUnreachable(t *testing.T) { + p := newPausePlugin(t, 3, "http://127.0.0.1:1", "deny") + p.mu.Lock() + p.cache["sess-1"] = &counters{tokens: 0, calls: 3, startedAt: time.Now()} + p.mu.Unlock() + + action := p.OnRequest(context.Background(), makePctx("sess-1", 0)) + if action.Type != pipeline.Reject { + t.Fatalf("expected Reject on unreachable webhook (pause_timeout_action=deny), got %v", action.Type) + } +} + +func TestOnRequest_PauseGraceWindow(t *testing.T) { + webhookCalls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + webhookCalls++ + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"action":"approve"}`)) + })) + defer srv.Close() + + p := New() + cfg := fmt.Sprintf(`{ + "redis_url": "mem://test", + "max_calls": 3, + "on_exceed": "pause", + "pause_webhook": %q, + "pause_timeout": "2s", + "pause_timeout_action": "deny", + "pause_grace_period": "10m", + "refresh_interval": "100ms" + }`, srv.URL) + if err := p.Configure(json.RawMessage(cfg)); err != nil { + t.Fatalf("Configure: %v", err) + } + p.store = newMemStore() + p.httpClient = &http.Client{Timeout: 0} + + p.mu.Lock() + p.cache["sess"] = &counters{tokens: 0, calls: 3, startedAt: time.Now()} + p.mu.Unlock() + + // First request fires the webhook. + action := p.OnRequest(context.Background(), makePctx("sess", 0)) + if action.Type != pipeline.Continue { + t.Fatalf("first request: expected Continue, got %v", action.Type) + } + if webhookCalls != 1 { + t.Fatalf("expected 1 webhook call, got %d", webhookCalls) + } + + // Second request within grace window skips the webhook. + action = p.OnRequest(context.Background(), makePctx("sess", 0)) + if action.Type != pipeline.Continue { + t.Fatalf("second request (grace): expected Continue, got %v", action.Type) + } + if webhookCalls != 1 { + t.Fatalf("expected still 1 webhook call after grace, got %d", webhookCalls) + } +} + +func TestOnRequest_PauseGraceExpired(t *testing.T) { + webhookCalls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + webhookCalls++ + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"action":"approve"}`)) + })) + defer srv.Close() + + p := New() + cfg := fmt.Sprintf(`{ + "redis_url": "mem://test", + "max_calls": 3, + "on_exceed": "pause", + "pause_webhook": %q, + "pause_timeout": "2s", + "pause_timeout_action": "deny", + "pause_grace_period": "10m", + "refresh_interval": "100ms" + }`, srv.URL) + if err := p.Configure(json.RawMessage(cfg)); err != nil { + t.Fatalf("Configure: %v", err) + } + p.store = newMemStore() + p.httpClient = &http.Client{Timeout: 0} + + // Seed cache with lastApprovedAt already expired (11 minutes ago > 10m grace). + p.mu.Lock() + p.cache["sess"] = &counters{ + tokens: 0, + calls: 3, + startedAt: time.Now(), + lastApprovedAt: time.Now().Add(-11 * time.Minute), + } + p.mu.Unlock() + + // Request after grace expired fires webhook. + p.OnRequest(context.Background(), makePctx("sess", 0)) + if webhookCalls != 1 { + t.Fatalf("expected 1 webhook call after grace expired, got %d", webhookCalls) + } +} + func TestEvaluate_MultipleLimits(t *testing.T) { p := newTestPlugin(100, 10, 60) From 668f95622b3a88ebc6d513dc5df7df371c1d786f Mon Sep 17 00:00:00 2001 From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:49:05 -0600 Subject: [PATCH 05/29] :bug: Preserve lastApprovedAt in cache Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> --- .../authlib/plugins/sessionbudget/plugin.go | 4 +- .../plugins/sessionbudget/plugin_test.go | 59 +++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/authbridge/authlib/plugins/sessionbudget/plugin.go b/authbridge/authlib/plugins/sessionbudget/plugin.go index d4cfe238c..b303be543 100644 --- a/authbridge/authlib/plugins/sessionbudget/plugin.go +++ b/authbridge/authlib/plugins/sessionbudget/plugin.go @@ -432,6 +432,7 @@ func (p *SessionBudget) refreshCache() { } p.mu.Lock() + var lastApprovedAt time.Time if existing, ok := p.cache[sessionID]; ok { // Take the max of local and Redis to avoid regressing counters when // in-flight accumulate goroutines haven't committed to Redis yet. @@ -444,8 +445,9 @@ func (p *SessionBudget) refreshCache() { if startedAt.IsZero() && !existing.startedAt.IsZero() { startedAt = existing.startedAt } + lastApprovedAt = existing.lastApprovedAt } - p.cache[sessionID] = &counters{tokens: tokens, calls: calls, startedAt: startedAt} + p.cache[sessionID] = &counters{tokens: tokens, calls: calls, startedAt: startedAt, lastApprovedAt: lastApprovedAt} p.mu.Unlock() } } diff --git a/authbridge/authlib/plugins/sessionbudget/plugin_test.go b/authbridge/authlib/plugins/sessionbudget/plugin_test.go index 8e48e5a4e..f70b76a70 100644 --- a/authbridge/authlib/plugins/sessionbudget/plugin_test.go +++ b/authbridge/authlib/plugins/sessionbudget/plugin_test.go @@ -596,6 +596,65 @@ func TestOnRequest_PauseWebhookUnreachable(t *testing.T) { } } +func TestRefreshCache_PreservesLastApprovedAt(t *testing.T) { + webhookCalls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + webhookCalls++ + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"action":"approve"}`)) + })) + defer srv.Close() + + p := New() + cfg := fmt.Sprintf(`{ + "redis_url": "mem://test", + "max_calls": 3, + "on_exceed": "pause", + "pause_webhook": %q, + "pause_timeout": "2s", + "pause_timeout_action": "deny", + "pause_grace_period": "10m", + "refresh_interval": "100ms" + }`, srv.URL) + if err := p.Configure(json.RawMessage(cfg)); err != nil { + t.Fatalf("Configure: %v", err) + } + store := newMemStore() + p.store = store + p.httpClient = &http.Client{Timeout: 0} + + // Seed cache at limit and fire first request to get approval. + p.mu.Lock() + p.cache["sess"] = &counters{tokens: 0, calls: 3, startedAt: time.Now()} + p.mu.Unlock() + + action := p.OnRequest(context.Background(), makePctx("sess", 0)) + if action.Type != pipeline.Continue { + t.Fatalf("expected Continue after approval, got %v", action.Type) + } + if webhookCalls != 1 { + t.Fatalf("expected 1 webhook call, got %d", webhookCalls) + } + + // Simulate Redis having authoritative counters. + ctx := context.Background() + store.HashIncr(ctx, "session-budget:sess", "tokens", 100) + store.HashIncr(ctx, "session-budget:sess", "calls", 5) + store.HashSetNX(ctx, "session-budget:sess", "started_at", "1700000000") + + // Refresh replaces counters from Redis — must preserve lastApprovedAt. + p.refreshCache() + + // Second request should still be within grace (no new webhook call). + action = p.OnRequest(context.Background(), makePctx("sess", 0)) + if action.Type != pipeline.Continue { + t.Fatalf("expected Continue within grace after refresh, got %v", action.Type) + } + if webhookCalls != 1 { + t.Fatalf("expected still 1 webhook call after refresh, got %d", webhookCalls) + } +} + func TestOnRequest_PauseGraceWindow(t *testing.T) { webhookCalls := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { From afe46a7a71d38c2de612f334614bf33ad5756014 Mon Sep 17 00:00:00 2001 From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:58:35 -0600 Subject: [PATCH 06/29] :memo: Update plugin name in catalog Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> --- authbridge/docs/plugin-catalog.md | 37 ++++++++++++++++++------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/authbridge/docs/plugin-catalog.md b/authbridge/docs/plugin-catalog.md index f533e25c5..19960266b 100644 --- a/authbridge/docs/plugin-catalog.md +++ b/authbridge/docs/plugin-catalog.md @@ -33,8 +33,8 @@ AuthBridge pipeline YAML, not whether it is compiled into the binary | [`opa`](#opa) | OPA policy enforcement for inbound and outbound requests. | Alpha | Both | No | | [`sparc`](#sparc) | Pre-tool reflection: blocks ungrounded/hallucinated tool calls. | Alpha | Outbound | No | | [`static-inject`](#static-inject) | Swaps a placeholder credential for a real static credential on outbound requests. | Alpha | Outbound | No | +| [`session-budget`](#session-budget) | Enforces per-session token, call, and duration budgets via Redis. | Alpha | Outbound | No | | [`token-broker`](#token-broker) | Exchanges incoming tokens against a configured IdP via a broker service. | Alpha | Outbound | No | -| [`token-budget`](#token-budget) | Enforces per-session token, call, and duration budgets via Redis. | Coming Soon | Outbound | No | | [`token-exchange`](#token-exchange) | RFC 8693 outbound token exchange per route. | Ready | Outbound | YES | ## `a2a-parser` @@ -169,6 +169,26 @@ outbound requests, so the workload never holds the real secret. - `placeholder` (string) — if set, the inbound bearer must exactly equal this value before injection proceeds. - `inject_header` (string) — header to inject the credential into. Default `Authorization` (writes `Bearer `); any other value writes the raw credential and drops the inbound `Authorization` header. +## `session-budget` + +Enforces per-session token, call-count, and duration budgets via Redis. Opt-in at build time (`-tags include_plugin_sessionbudget`). + +- `redis_url` (string) — Redis/Valkey connection URL; required. +- `max_tokens` (int64) — cumulative token ceiling per session. `0` = no limit. +- `max_calls` (int64) — max inference calls per session. `0` = no limit. +- `max_duration_seconds` (int64) — wall-clock session lifetime. `0` = no limit. +- `on_exceed` (string) — `deny` (default, block), `observe` (log only), or `pause` (HITL webhook approval). +- `pause_webhook` (string) — URL to POST for approval when `on_exceed=pause`. Required in pause mode. +- `pause_timeout` (string) — how long to wait for webhook response. Default `30s`. +- `pause_timeout_action` (string) — fallback on timeout/error: `deny` (default) or `allow`. +- `pause_grace_period` (string) — suppress repeated webhooks after approval. Default `5m`. +- `session_ttl_seconds` (int) — Redis key TTL; should be ≥ `max_duration_seconds`. Default 7200. +- `refresh_interval` (string) — how often the local cache syncs from Redis. Default `5s`. +- `redis_unavailable` (string) — only `fail_open` (default) is implemented; `fail_closed` is rejected at Configure time. + +Note that enforcement uses a zero-I/O local cache. After a pod restart, the first request can pass while the cache is cold, before Redis refresh restores the counters. + + ## `token-broker` Exchanges incoming tokens against a configured IdP through an external @@ -182,21 +202,6 @@ token broker service, per host-based routing rules. - `action` — `broker` (default) or `passthrough`. - `authorization_endpoint` / `token_endpoint` — per-route OAuth endpoint overrides sent to the broker. -## `token-budget` - -Enforces per-session token, call-count, and duration budgets via Redis. -Opt-in at build time (`-tags include_plugin_tokenbudget`). - -- `redis_url` (string) — Redis/Valkey connection URL; required. -- `max_tokens` (int64) — cumulative token ceiling per session. `0` = no limit. -- `max_calls` (int64) — max inference calls per session. `0` = no limit. -- `max_duration_seconds` (int64) — wall-clock session lifetime. `0` = no limit. -- `on_exceed` (string) — `deny` (default, block) or `observe` (log only). -- `session_ttl_seconds` (int) — Redis key TTL; should be ≥ `max_duration_seconds`. Default 7200. -- `refresh_interval` (string) — how often the local cache syncs from Redis. Default `5s`. -- `redis_unavailable` (string) — only `fail_open` (default) is implemented; `fail_closed` is rejected at Configure time. - -Note that enforcement uses a zero-I/O local cache. After a pod restart, the first request can pass while the cache is cold, before Redis refresh restores the counters. ## `token-exchange` From ecf795df396eb6cd1ee1da943c8aa5e29b8c8e23 Mon Sep 17 00:00:00 2001 From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:01:32 -0600 Subject: [PATCH 07/29] :memo::art: Session budget plugin improvements Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> --- .../authlib/plugins/sessionbudget/plugin.go | 30 ++- .../plugins/sessionbudget/plugin_test.go | 42 ++++ authbridge/docs/session-budget-plugin.md | 227 ++++++++---------- 3 files changed, 163 insertions(+), 136 deletions(-) diff --git a/authbridge/authlib/plugins/sessionbudget/plugin.go b/authbridge/authlib/plugins/sessionbudget/plugin.go index b303be543..c7d948fcd 100644 --- a/authbridge/authlib/plugins/sessionbudget/plugin.go +++ b/authbridge/authlib/plugins/sessionbudget/plugin.go @@ -37,10 +37,11 @@ type config struct { } type counters struct { - tokens int64 - calls int64 - startedAt time.Time - lastApprovedAt time.Time + tokens int64 + calls int64 + startedAt time.Time + lastApprovedAt time.Time + pendingApproval bool } // SessionBudget is the plugin state. Redis provides cross-pod durability; @@ -145,6 +146,7 @@ func (p *SessionBudget) Init(_ context.Context) error { p.store = store if p.cfg.OnExceed == "pause" && p.httpClient == nil { + // Timeout: 0 — per-request deadline is set via context in callPauseWebhook. p.httpClient = &http.Client{Timeout: 0} } @@ -195,22 +197,34 @@ func (p *SessionBudget) OnRequest(ctx context.Context, pctx *pipeline.Context) p return pipeline.Action{Type: pipeline.Continue} case "pause": + // Grace window: skip webhook if recently approved or another request is already waiting. if p.gracePeriod > 0 && !c.lastApprovedAt.IsZero() && time.Since(c.lastApprovedAt) < p.gracePeriod { c.calls++ p.mu.Unlock() return pipeline.Action{Type: pipeline.Continue} } + if c.pendingApproval { + // Another goroutine is already calling the webhook — piggyback on grace. + c.calls++ + p.mu.Unlock() + return pipeline.Action{Type: pipeline.Continue} + } + c.pendingApproval = true c.calls++ p.mu.Unlock() p.log.Info("budget exceeded, requesting approval", "session", sessionID, "reason", reason) - if p.callPauseWebhook(ctx, sessionID, reason, &snap) { - p.mu.Lock() - if cc, ok := p.cache[sessionID]; ok { + approved := p.callPauseWebhook(ctx, sessionID, reason, &snap) + p.mu.Lock() + if cc, ok := p.cache[sessionID]; ok { + cc.pendingApproval = false + if approved { cc.lastApprovedAt = time.Now() } - p.mu.Unlock() + } + p.mu.Unlock() + if approved { return pipeline.Action{Type: pipeline.Continue} } details := p.buildDetails(&snap) diff --git a/authbridge/authlib/plugins/sessionbudget/plugin_test.go b/authbridge/authlib/plugins/sessionbudget/plugin_test.go index f70b76a70..e30821a59 100644 --- a/authbridge/authlib/plugins/sessionbudget/plugin_test.go +++ b/authbridge/authlib/plugins/sessionbudget/plugin_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "sync" + "sync/atomic" "testing" "time" @@ -747,6 +748,47 @@ func TestOnRequest_PauseGraceExpired(t *testing.T) { } } +func TestOnRequest_PausePendingApprovalSentinel(t *testing.T) { + var webhookCalls int32 + started := make(chan struct{}) + proceed := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&webhookCalls, 1) + close(started) + <-proceed + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"action":"approve"}`)) + })) + defer srv.Close() + + p := newPausePlugin(t, 3, srv.URL, "deny") + p.mu.Lock() + p.cache["sess"] = &counters{tokens: 0, calls: 3, startedAt: time.Now()} + p.mu.Unlock() + + // First goroutine fires the webhook and blocks. + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + p.OnRequest(context.Background(), makePctx("sess", 0)) + }() + <-started // webhook is in-flight + + // Second concurrent request should piggyback (pendingApproval=true). + action := p.OnRequest(context.Background(), makePctx("sess", 0)) + if action.Type != pipeline.Continue { + t.Fatalf("concurrent request: expected Continue (piggyback), got %v", action.Type) + } + + close(proceed) // unblock the webhook + wg.Wait() + + if c := atomic.LoadInt32(&webhookCalls); c != 1 { + t.Errorf("webhook called %d times, want exactly 1 (sentinel prevents thundering herd)", c) + } +} + func TestEvaluate_MultipleLimits(t *testing.T) { p := newTestPlugin(100, 10, 60) diff --git a/authbridge/docs/session-budget-plugin.md b/authbridge/docs/session-budget-plugin.md index d5d4181bd..4ce735fef 100644 --- a/authbridge/docs/session-budget-plugin.md +++ b/authbridge/docs/session-budget-plugin.md @@ -1,39 +1,27 @@ # session-budget Plugin -Enforces per-session lifetime budgets on tokens, inference calls, and -wall-clock duration. Supports three `on_exceed` modes: +Enforces per-session budgets on tokens, inference calls, and wall-clock +duration. Supports three `on_exceed` modes: - `deny` — return 403 (default) -- `observe` — shadow mode: log without blocking -- `pause` — HITL: fire a webhook for human/system approval before continuing +- `observe` — shadow mode: log without blocking, useful for calibrating limits +- `pause` — HITL: POST to a webhook for human/system approval before continuing -Uses Redis for cross-pod durable counters; evaluates limits from a local -cache with zero I/O on the hot path. +A "session" is the AuthBridge session ID (typically one A2A conversation +or agent task invocation). Redis holds durable counters across pods; a +local cache serves the hot path with zero I/O. -A "session" maps to the AuthBridge session ID (typically one A2A conversation -or agent task invocation). +## Build -## Build Tag - -This plugin is **opt-IN**. Build with `-tags include_plugin_sessionbudget` -to include it (and its `storage/redis` dependency) in the binary: +Opt-in — build with `-tags include_plugin_sessionbudget`: ```bash -cd authbridge docker build -f cmd/authbridge-proxy/Dockerfile \ --build-arg GO_BUILD_TAGS="include_plugin_sessionbudget" \ -t authbridge:latest . ``` -Without the tag, neither session-budget nor go-redis are linked. - -The same build tag works for the envoy-sidecar image: - -```bash -docker build -f cmd/authbridge-envoy/Dockerfile \ - --build-arg GO_BUILD_TAGS="include_plugin_sessionbudget" \ - -t authbridge-envoy:latest . -``` +Same tag works for `cmd/authbridge-envoy/Dockerfile`. ## Configuration @@ -52,47 +40,68 @@ pipeline: - name: inference-parser ``` -| Field | Required | Default | Description | -|-------|----------|---------|-------------| -| `redis_url` | yes | — | Redis/Valkey connection URL | -| `max_tokens` | no | 0 | Cumulative token ceiling per session (0 = no limit) | -| `max_calls` | no | 0 | Max inference calls per session (0 = no limit) | -| `max_duration_seconds` | no | 0 | Wall-clock session lifetime in seconds (0 = no limit) | -| `on_exceed` | no | "deny" | `deny`, `observe`, or `pause` | -| `pause_webhook` | no | — | URL to POST for approval (required when `on_exceed=pause`) | -| `pause_timeout` | no | "30s" | How long to wait for webhook response | -| `pause_timeout_action` | no | "deny" | Action on webhook timeout/error: `deny` or `allow` | -| `pause_grace_period` | no | "5m" | After approval, suppress further webhooks for this duration | -| `session_ttl_seconds` | no | 7200 | Redis key TTL; should be >= `max_duration_seconds` | -| `refresh_interval` | no | "5s" | How often to sync local cache from Redis | -| `redis_unavailable` | no | "fail_open" | Only `fail_open` supported (stale cache retained on failure). `fail_closed` reserved. | +| Field | Default | Description | +|-------|---------|-------------| +| `redis_url` | — (required) | Redis/Valkey URL | +| `max_tokens` | 0 | Token ceiling (0 = no limit) | +| `max_calls` | 0 | Inference call cap (0 = no limit) | +| `max_duration_seconds` | 0 | Session lifetime cap (0 = no limit) | +| `on_exceed` | `deny` | `deny` (403), `observe` (log only), or `pause` (webhook) | +| `pause_webhook` | — | URL to POST on breach (required when `on_exceed=pause`) | +| `pause_timeout` | `30s` | Max wait for webhook response | +| `pause_timeout_action` | `deny` | Fallback on timeout/error: `deny` or `allow` | +| `pause_grace_period` | `5m` | Suppress repeat webhooks after approval | +| `session_ttl_seconds` | 7200 | Redis key TTL; must be ≥ `max_duration_seconds` | +| `refresh_interval` | `5s` | Local-cache sync interval | +| `redis_unavailable` | `fail_open` | Only `fail_open` supported today | + +At least one of `max_tokens`, `max_calls`, `max_duration_seconds` must be > 0. -At least one of `max_tokens`, `max_calls`, or `max_duration_seconds` must be > 0. +**Pipeline position:** must appear **before** `inference-parser` on the +outbound pipeline. Both must be present for token counting (inference-parser +supplies the token counts session-budget accumulates). -**Local Redis/Valkey** for development: `docker run -d --name valkey -p 6379:6379 valkey/valkey:latest`. -Use `redis://localhost:6379` or `redis://host.docker.internal:6379` (container mode). +## Modes + +### `deny` (default) + +Returns 403 with a JSON body: + +```json +{ + "error": "budget.exceeded", + "message": "token limit reached: 50200/50000", + "details": { + "spent_tokens": 50200, + "spent_calls": 42, + "token_limit": 50000, + "call_limit": 100, + "duration_seconds": 1205, + "duration_limit": 1800 + } +} +``` -## Shadow Mode (observe) +`duration_seconds` / `duration_limit` are included only when +`max_duration_seconds` is set. -Set `on_exceed: "observe"` to run the plugin in shadow mode. The plugin -still accumulates counters and evaluates limits, but instead of blocking -requests it logs a WARN and continues the pipeline. Use this to calibrate -limits under real workloads before enabling enforcement. +### `observe` (shadow mode) -Rollout workflow: -1. Deploy with `on_exceed: "observe"` and conservative limits -2. Monitor logs for `"budget exceeded (shadow mode)"` entries -3. Adjust `max_tokens` / `max_calls` / `max_duration_seconds` based on observed patterns -4. Flip to `on_exceed: "deny"` when confident in the thresholds +Counters still accumulate and limits are still evaluated, but breaches +only emit a WARN log (`"budget exceeded (shadow mode)"`) and the request +continues. Use to calibrate limits before enforcing: -## Pause Mode (HITL) +1. Deploy with `on_exceed: observe` and conservative limits. +2. Watch logs for shadow-mode entries. +3. Adjust `max_tokens` / `max_calls` / `max_duration_seconds` to fit real workloads. +4. Flip to `on_exceed: deny` (or `pause`) once confident. -Set `on_exceed: "pause"` to enable human-in-the-loop approval. When budget -is exceeded, the plugin fires a synchronous HTTP POST to `pause_webhook` -and blocks the request until a response arrives (up to `pause_timeout`). +### `pause` (HITL webhook) -### Webhook request body (POST) +On breach, POST to `pause_webhook` and block the request until the +webhook responds or `pause_timeout` fires. +**Request body:** ```json { "session_id": "abc-123", @@ -106,8 +115,7 @@ and blocks the request until a response arrives (up to `pause_timeout`). } ``` -### Webhook expected response - +**Expected response:** ```json {"action": "approve"} ``` @@ -116,91 +124,54 @@ or {"action": "deny", "reason": "operator rejected"} ``` -### Behavior on approval +**On approval:** the request continues, and subsequent requests from +the same session skip the webhook for `pause_grace_period` (default +`5m`). This prevents per-request webhook spam once a session is +approved. Concurrent breaches during an in-flight webhook piggyback on +the pending call rather than each firing their own. -After `{"action": "approve"}`, the request continues and subsequent requests -within `pause_grace_period` (default 5m) pass without re-invoking the webhook. -This prevents per-request webhook spam once a session is approved to continue. +**On timeout / non-200 / bad JSON / unreachable:** falls back to +`pause_timeout_action` (`deny` returns 403; `allow` continues). -### Behavior on timeout/error +The grace window is pod-local (in-memory). In multi-pod deployments +without sticky sessions, each pod fires one webhook before its own +grace kicks in. -If the webhook is unreachable, returns non-200, returns invalid JSON, or -exceeds `pause_timeout`, the plugin falls back to `pause_timeout_action`: -- `"deny"` (default) — reject with 403 -- `"allow"` — continue the pipeline - -### Design notes +## Failure Modes -- The request goroutine blocks during the webhook call (same pattern as IBAC's LLM judge) -- Client disconnect cancels the request context, which cancels the webhook call -- The grace period is pod-local (in-memory cache); worst case after pod restart is one extra webhook call -- Concurrent requests from the same session may both fire webhooks (acceptable for v1) +| Scenario | Behavior | +|----------|----------| +| Redis down at startup | Fail-open until refresh populates cache | +| Redis fails mid-session | Local cache keeps enforcing; writes dropped | +| Pod restart | First request passes (cold cache); refresh restores counters within `refresh_interval` | +| Webhook unreachable | Falls back to `pause_timeout_action` | -## Pipeline Position +Infrastructure failures never produce false denials — Redis +unavailability degrades enforcement to local-cache-only (no cross-pod +consistency) rather than blocking requests. -Must be declared **before** `inference-parser` in the outbound plugin list. -The response path runs in reverse order, so inference-parser finalizes token -counts first, then session-budget reads them. Both plugins implement -`StreamingResponder` so they work with streaming SSE responses (Ollama, -LiteLLM, OpenAI) and buffered JSON responses alike. +**Token counting requires `usage` in provider responses.** Providers +that omit `usage` from streaming chunks (e.g. Anthropic via LiteLLM) +will show `promptTokens=0` in inference-parser logs — `max_tokens` +enforcement won't trigger, but `max_calls` and `max_duration_seconds` +still apply. Ollama, OpenAI, and Azure OpenAI include usage in +streaming responses and work fully. -## Response Format (403) +## Redis Keys -```json -{ - "error": "budget.exceeded", - "message": "token limit reached: 50200/50000", - "details": { - "spent_tokens": 50200, - "spent_calls": 42, - "token_limit": 50000, - "call_limit": 100, - "duration_seconds": 1205, - "duration_limit": 1800 - } -} ``` - -`duration_seconds` and `duration_limit` are included only when `max_duration_seconds` is configured. - -## Redis Key Schema - -```text -session-budget: (Hash, TTL = session_ttl_seconds) - tokens int cumulative TotalTokens - calls int inference call count - started_at unix first-call timestamp (set-if-not-exists) +session-budget: (Hash, TTL = session_ttl_seconds) + tokens cumulative token count + calls inference call count + started_at first-call unix timestamp ``` -**Migration note:** The Redis key prefix changed from `token-budget:` to -`session-budget:`. Existing keys from prior deployments will be orphaned -and expire within `session_ttl_seconds`. - -## Failure Modes - -| Scenario | Behavior | -|----------|----------| -| Redis down at startup | `Init` succeeds (no connectivity check); enforcement fail-open until first refresh populates cache | -| Redis fails mid-session | Local cache continues enforcing; writes dropped silently | -| Pod restarts | First request passes (cold cache); refresh picks up Redis counters within one interval | -| Provider returns no usage data | `max_tokens` not enforced; `max_calls` and `max_duration_seconds` still work | -| Pause webhook unreachable | Falls back to `pause_timeout_action` (default: deny) | - -**Fail-open guarantee:** The plugin never blocks requests due to its own infrastructure failures. Redis unavailability degrades enforcement (local cache only, no cross-pod consistency) but never causes false denials. - -**Note on token counting:** Token accumulation requires the LLM provider to -return `usage` (prompt/completion token counts) in responses. Providers that -omit usage from streaming chunks (e.g. Anthropic via LiteLLM) will show -`promptTokens=0` in inference-parser logs — `max_tokens` enforcement won't -trigger for these providers, but `max_calls` and `max_duration_seconds` still -apply. Ollama, OpenAI, and Azure OpenAI include usage in streaming responses -and work fully. - -## Testing +## Local Development ```bash +docker run -d --name valkey -p 6379:6379 valkey/valkey:latest +# redis_url: redis://localhost:6379 (or host.docker.internal from a container) + cd authbridge/authlib go test ./plugins/sessionbudget/... -v -count=1 ``` - -No external dependencies — tests use in-memory stores. From b2fcc92827fb8621340a4a0f4a7249f45635437b Mon Sep 17 00:00:00 2001 From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:19:33 -0600 Subject: [PATCH 08/29] :memo: Document webhook info Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> --- authbridge/demos/README.md | 1 + authbridge/demos/session-budget/README.md | 38 +++++ .../k8s/pause-webhook-stub.yaml | 74 ++++++++++ authbridge/docs/session-budget-plugin.md | 132 ++++++++++++++---- 4 files changed, 215 insertions(+), 30 deletions(-) create mode 100644 authbridge/demos/session-budget/README.md create mode 100644 authbridge/demos/session-budget/k8s/pause-webhook-stub.yaml diff --git a/authbridge/demos/README.md b/authbridge/demos/README.md index 690a89be2..f44bf8fe3 100644 --- a/authbridge/demos/README.md +++ b/authbridge/demos/README.md @@ -20,6 +20,7 @@ more AuthBridge capabilities. | **[GitHub Issue Agent](github-issue/demo.md)** | Intermediate | Inbound validation + outbound token exchange + scope-based access control | [UI](github-issue/demo-ui.md) or [Manual](github-issue/demo-manual.md) | | **[Token-Exchange Routes](token-exchange-routes/README.md)** | Reference | How to write `authproxy-routes` for single- and multi-target token exchange | Configuration only | | **[MCP Parser Plugin](mcp-parser/README.md)** | Reference | Enable the `mcp-parser` plugin to surface tool calls / resource reads in session events | Configuration only | +| **[Session Budget](session-budget/README.md)** | Reference | Test assets for the `session-budget` plugin, including a pause-mode webhook stub | kubectl | | **[abctl Walkthrough](weather-agent/demo-with-abctl.md)** | Reference | Watch the AuthBridge plugin pipeline live with the `abctl` TUI | Tooling only | | **[IBAC](ibac/README.md)** | Intermediate | Intent-Based Access Control: LLM judge denies outbound HTTP that doesn't align with the user's recorded intent. Reproduces the email-poison / prompt-injection attack from `huang195/ibac`; chat with the agent through the rossoctl UI and see the exfiltration blocked, then `make show-result` for a pipeline-level forensic | UI + kubectl | | **[SPARC (finance)](finance-sparc/README.md)** | Intermediate | SPARC pre-tool reflection: the `sparc` plugin blocks a hallucinated/ungrounded tool argument (an invented transaction id) before it executes and transparently asks the user to clarify, then approves the corrected call. Complements IBAC — SPARC verifies argument grounding, IBAC verifies intent alignment | UI + kubectl | diff --git a/authbridge/demos/session-budget/README.md b/authbridge/demos/session-budget/README.md new file mode 100644 index 000000000..c8f2c8133 --- /dev/null +++ b/authbridge/demos/session-budget/README.md @@ -0,0 +1,38 @@ +# session-budget demo assets + +Deployable helpers for exercising the `session-budget` plugin. For plugin +configuration and mode semantics, see +[`../../docs/session-budget-plugin.md`](../../docs/session-budget-plugin.md). + +## `k8s/pause-webhook-stub.yaml` + +Minimal HITL webhook that returns `{"action":"approve"}` for every POST — enough +to smoke-test `on_exceed: pause` end-to-end. Also logs each incoming request +body so you can see exactly what session-budget sent. + +```bash +kubectl apply -f k8s/pause-webhook-stub.yaml +``` + +Then point the plugin at it: + +```yaml +pipeline: + outbound: + plugins: + - name: session-budget + config: + redis_url: "redis://valkey.infra.svc:6379" + max_calls: 5 + on_exceed: pause + pause_webhook: "http://pause-webhook-stub.team1.svc.cluster.local" +``` + +Follow the webhook logs: + +```bash +kubectl logs -n team1 deploy/pause-webhook-stub -f +``` + +To exercise the deny path, edit the inline Python in the manifest to write +`{"action":"deny"}` and re-apply. diff --git a/authbridge/demos/session-budget/k8s/pause-webhook-stub.yaml b/authbridge/demos/session-budget/k8s/pause-webhook-stub.yaml new file mode 100644 index 000000000..196b53390 --- /dev/null +++ b/authbridge/demos/session-budget/k8s/pause-webhook-stub.yaml @@ -0,0 +1,74 @@ +# Minimal pause-mode webhook stub for the session-budget plugin. +# Returns {"action":"approve"} for every POST — use to smoke-test the +# on_exceed: pause flow end-to-end. For deny-path testing, edit the inline +# Python to write {"action":"deny"} instead. +# +# Usage: +# kubectl apply -f pause-webhook-stub.yaml +# # In session-budget config: +# # pause_webhook: "http://pause-webhook-stub.team1.svc.cluster.local" +# +# Logs: +# kubectl logs -n team1 deploy/pause-webhook-stub +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: pause-webhook-stub + namespace: team1 + labels: + app: pause-webhook-stub +spec: + replicas: 1 + selector: + matchLabels: + app: pause-webhook-stub + template: + metadata: + labels: + app: pause-webhook-stub + spec: + containers: + - name: stub + image: python:3.12-alpine + command: ["python", "-c"] + args: + - | + import http.server, json, sys + class Handler(http.server.BaseHTTPRequestHandler): + def do_POST(self): + n = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(n).decode("utf-8", "replace") + print(f"[pause-webhook] POST {self.path} body={body}", flush=True) + resp = b'{"action":"approve"}' + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(resp))) + self.end_headers() + self.wfile.write(resp) + def log_message(self, *a, **k): + pass + http.server.HTTPServer(("", 8080), Handler).serve_forever() + ports: + - name: http + containerPort: 8080 + resources: + requests: + cpu: 10m + memory: 32Mi + limits: + cpu: 100m + memory: 64Mi +--- +apiVersion: v1 +kind: Service +metadata: + name: pause-webhook-stub + namespace: team1 +spec: + selector: + app: pause-webhook-stub + ports: + - name: http + port: 80 + targetPort: 8080 diff --git a/authbridge/docs/session-budget-plugin.md b/authbridge/docs/session-budget-plugin.md index 4ce735fef..382e51fed 100644 --- a/authbridge/docs/session-budget-plugin.md +++ b/authbridge/docs/session-budget-plugin.md @@ -1,15 +1,15 @@ # session-budget Plugin -Enforces per-session budgets on tokens, inference calls, and wall-clock -duration. Supports three `on_exceed` modes: +Enforces per-session budgets on tokens, inference calls, and wall-clock duration. +Supports three `on_exceed` modes: - `deny` — return 403 (default) - `observe` — shadow mode: log without blocking, useful for calibrating limits - `pause` — HITL: POST to a webhook for human/system approval before continuing -A "session" is the AuthBridge session ID (typically one A2A conversation -or agent task invocation). Redis holds durable counters across pods; a -local cache serves the hot path with zero I/O. +A "session" is the AuthBridge session ID (typically one A2A conversation or agent +task invocation). Redis holds durable counters across pods; a local cache serves +the hot path with zero I/O. ## Build @@ -57,9 +57,9 @@ pipeline: At least one of `max_tokens`, `max_calls`, `max_duration_seconds` must be > 0. -**Pipeline position:** must appear **before** `inference-parser` on the -outbound pipeline. Both must be present for token counting (inference-parser -supplies the token counts session-budget accumulates). +**Pipeline position:** must appear **before** `inference-parser` on the outbound +pipeline. Both must be present for token counting (inference-parser supplies the +token counts session-budget accumulates). ## Modes @@ -87,9 +87,9 @@ Returns 403 with a JSON body: ### `observe` (shadow mode) -Counters still accumulate and limits are still evaluated, but breaches -only emit a WARN log (`"budget exceeded (shadow mode)"`) and the request -continues. Use to calibrate limits before enforcing: +Counters still accumulate and limits are still evaluated, but breaches only emit +a WARN log (`"budget exceeded (shadow mode)"`) and the request continues. Use to +calibrate limits before enforcing: 1. Deploy with `on_exceed: observe` and conservative limits. 2. Watch logs for shadow-mode entries. @@ -98,8 +98,37 @@ continues. Use to calibrate limits before enforcing: ### `pause` (HITL webhook) -On breach, POST to `pause_webhook` and block the request until the -webhook responds or `pause_timeout` fires. +On breach, POST to `pause_webhook` and block the request until the webhook +responds or `pause_timeout` fires. + +**What the webhook is.** Any HTTP endpoint that speaks the contract below. You +build and operate it — session-budget doesn't ship one. Common shapes: + +- **A Kubernetes Service** in the cluster (e.g. an approval controller or a + small in-house service that decides based on session metadata). +- **A workflow entrypoint** — Temporal, Argo, GitHub Actions + `repository_dispatch`, Slack/PagerDuty middleware, etc. — that synchronously + blocks on an operator's response. +- **A stub for local development** — a tiny handler returning a hardcoded + `{"action":"approve"}` for smoke tests. + +Whatever it is, `pause_webhook` must be reachable from the AuthBridge pod on the +outbound path and return within `pause_timeout` (default `30s`) or the plugin +falls back to `pause_timeout_action`. + +**Contract:** + +| Aspect | Requirement | +|--------|-------------| +| Method | `POST` | +| URL | Exactly the `pause_webhook` value (no path templating) | +| Request `Content-Type` | `application/json` | +| Request body | See below — always the same schema | +| Success response | HTTP `200` with `application/json` body containing an `action` field | +| Response body cap | 4 KiB (larger responses are truncated at decode) | +| Latency budget | Must respond within `pause_timeout`; slow webhooks block the caller | +| Auth | None injected by the plugin — add your own (mTLS, network policy, IP allowlist) at the transport layer | +| Retries | None — the plugin calls once per breach | **Request body:** ```json @@ -124,18 +153,33 @@ or {"action": "deny", "reason": "operator rejected"} ``` -**On approval:** the request continues, and subsequent requests from -the same session skip the webhook for `pause_grace_period` (default -`5m`). This prevents per-request webhook spam once a session is -approved. Concurrent breaches during an in-flight webhook piggyback on -the pending call rather than each firing their own. +**On approval:** the request continues, and subsequent requests from the same +session skip the webhook for `pause_grace_period` (default `5m`). This prevents +per-request webhook spam once a session is approved. Concurrent breaches during +an in-flight webhook piggyback on the pending call rather than each firing +their own. **On timeout / non-200 / bad JSON / unreachable:** falls back to `pause_timeout_action` (`deny` returns 403; `allow` continues). -The grace window is pod-local (in-memory). In multi-pod deployments -without sticky sessions, each pod fires one webhook before its own -grace kicks in. +The grace window is pod-local (in-memory). In multi-pod deployments without +sticky sessions, each pod fires one webhook before its own grace kicks in. + +**Implementation tips for the webhook:** + +- **Key on `session_id`.** All fields in the request describe one session. Use + `session_id` as the correlation key if you queue, cache, or fan out to human + reviewers. +- **Respond fast, or make `pause_timeout` generous.** The caller's request + goroutine is blocked for the full webhook duration. If a human is in the + loop, either bump `pause_timeout` (minutes) or have the webhook return `deny` + immediately and approve out-of-band on a later request. +- **Idempotency isn't required** but is nice to have. Under bursty breaches + you'll typically see one call per (session, pod) before grace kicks in; + concurrent breaches on the same pod are coalesced. +- **Health matters.** An unreachable / 5xx / slow webhook falls back to + `pause_timeout_action`. If that's `deny`, an outage of your webhook turns + budget breaches into hard 403s. ## Failure Modes @@ -146,16 +190,15 @@ grace kicks in. | Pod restart | First request passes (cold cache); refresh restores counters within `refresh_interval` | | Webhook unreachable | Falls back to `pause_timeout_action` | -Infrastructure failures never produce false denials — Redis -unavailability degrades enforcement to local-cache-only (no cross-pod -consistency) rather than blocking requests. +Infrastructure failures never produce false denials — Redis unavailability +degrades enforcement to local-cache-only (no cross-pod consistency) rather than +blocking requests. -**Token counting requires `usage` in provider responses.** Providers -that omit `usage` from streaming chunks (e.g. Anthropic via LiteLLM) -will show `promptTokens=0` in inference-parser logs — `max_tokens` -enforcement won't trigger, but `max_calls` and `max_duration_seconds` -still apply. Ollama, OpenAI, and Azure OpenAI include usage in -streaming responses and work fully. +**Token counting requires `usage` in provider responses.** Providers that omit +`usage` from streaming chunks (e.g. Anthropic via LiteLLM) will show +`promptTokens=0` in inference-parser logs — `max_tokens` enforcement won't +trigger, but `max_calls` and `max_duration_seconds` still apply. Ollama, +OpenAI, and Azure OpenAI include usage in streaming responses and work fully. ## Redis Keys @@ -168,10 +211,39 @@ session-budget: (Hash, TTL = session_ttl_seconds) ## Local Development +**Redis / Valkey:** + ```bash docker run -d --name valkey -p 6379:6379 valkey/valkey:latest # redis_url: redis://localhost:6379 (or host.docker.internal from a container) +``` + +**Pause-mode webhook stub.** A one-liner that returns `approve` for every +request — enough to smoke-test `on_exceed: pause` end-to-end: + +```bash +docker run -d --name pause-webhook -p 8888:8888 python:3.12-alpine \ + python -c "import http.server,json; \ +h=type('H',(http.server.BaseHTTPRequestHandler,),{ \ +'do_POST':lambda s:(s.send_response(200), \ +s.send_header('Content-Type','application/json'),s.end_headers(), \ +s.wfile.write(b'{\"action\":\"approve\"}'))}); \ +http.server.HTTPServer(('',8888),h).serve_forever()" + +# pause_webhook: http://localhost:8888 (or http://host.docker.internal:8888) +``` + +Swap `approve` for `deny` to test the reject path. Logs land in +`docker logs pause-webhook`. + +For an in-cluster stub, apply +`authbridge/demos/session-budget/k8s/pause-webhook-stub.yaml` and set +`pause_webhook: http://pause-webhook-stub.team1.svc.cluster.local`. See +[`../demos/session-budget/README.md`](../demos/session-budget/README.md). +**Run the plugin tests:** + +```bash cd authbridge/authlib go test ./plugins/sessionbudget/... -v -count=1 ``` From c85e8a5b4a9caa5270c0d6cedb8467db441b0951 Mon Sep 17 00:00:00 2001 From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:03:34 -0600 Subject: [PATCH 09/29] :memo::bulb: Clarify piggyback and cache pending approval Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> --- authbridge/authlib/plugins/sessionbudget/plugin.go | 5 ++++- authbridge/docs/session-budget-plugin.md | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/authbridge/authlib/plugins/sessionbudget/plugin.go b/authbridge/authlib/plugins/sessionbudget/plugin.go index c7d948fcd..aba2568bb 100644 --- a/authbridge/authlib/plugins/sessionbudget/plugin.go +++ b/authbridge/authlib/plugins/sessionbudget/plugin.go @@ -447,6 +447,7 @@ func (p *SessionBudget) refreshCache() { p.mu.Lock() var lastApprovedAt time.Time + var pendingApproval bool if existing, ok := p.cache[sessionID]; ok { // Take the max of local and Redis to avoid regressing counters when // in-flight accumulate goroutines haven't committed to Redis yet. @@ -460,8 +461,10 @@ func (p *SessionBudget) refreshCache() { startedAt = existing.startedAt } lastApprovedAt = existing.lastApprovedAt + // Preserve mid-webhook: dropping would let a concurrent breach fire a duplicate. + pendingApproval = existing.pendingApproval } - p.cache[sessionID] = &counters{tokens: tokens, calls: calls, startedAt: startedAt, lastApprovedAt: lastApprovedAt} + p.cache[sessionID] = &counters{tokens: tokens, calls: calls, startedAt: startedAt, lastApprovedAt: lastApprovedAt, pendingApproval: pendingApproval} p.mu.Unlock() } } diff --git a/authbridge/docs/session-budget-plugin.md b/authbridge/docs/session-budget-plugin.md index 382e51fed..1bcc9c8cb 100644 --- a/authbridge/docs/session-budget-plugin.md +++ b/authbridge/docs/session-budget-plugin.md @@ -159,6 +159,10 @@ per-request webhook spam once a session is approved. Concurrent breaches during an in-flight webhook piggyback on the pending call rather than each firing their own. +Requests that arrive during an in-flight webhook piggyback on it and continue +optimistically. If the webhook ultimately denies, those extra requests have +already passed. + **On timeout / non-200 / bad JSON / unreachable:** falls back to `pause_timeout_action` (`deny` returns 403; `allow` continues). From b1f837d6ee9eed42fbe4944f25f7703cae7aa790 Mon Sep 17 00:00:00 2001 From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:30:03 -0600 Subject: [PATCH 10/29] :recycle: Test consolidation Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> --- .../authlib/plugins/sessionbudget/e2e_test.go | 148 ++++++++---------- .../plugins/sessionbudget/plugin_test.go | 106 +++++-------- 2 files changed, 101 insertions(+), 153 deletions(-) diff --git a/authbridge/authlib/plugins/sessionbudget/e2e_test.go b/authbridge/authlib/plugins/sessionbudget/e2e_test.go index e5eb4d974..3d20cc0ed 100644 --- a/authbridge/authlib/plugins/sessionbudget/e2e_test.go +++ b/authbridge/authlib/plugins/sessionbudget/e2e_test.go @@ -295,93 +295,69 @@ func TestE2E_ShadowMode(t *testing.T) { } } -// TestE2E_PauseMode verifies the full lifecycle: accumulate past limit, -// webhook approves, request passes through. +// TestE2E_PauseMode covers the full lifecycle for both webhook outcomes. +// The 'approve' row also verifies the request body carries session_id; +// the 'deny' row also verifies the 403 response schema. func TestE2E_PauseMode(t *testing.T) { - approveServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var req map[string]any - json.NewDecoder(r.Body).Decode(&req) - if req["session_id"] != "sess" { - t.Errorf("webhook got session_id=%v, want sess", req["session_id"]) - } - w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"action":"approve"}`)) - })) - defer approveServer.Close() - - p := New() - cfg, _ := json.Marshal(config{ - RedisURL: "mem://test", - MaxCalls: 3, - OnExceed: "pause", - PauseWebhook: approveServer.URL, - PauseTimeout: "5s", - PauseTimeoutAction: "deny", - RefreshInterval: "30ms", - RedisUnavailable: "fail_open", - }) - if err := p.Configure(cfg); err != nil { - t.Fatalf("Configure: %v", err) - } - p.store = newMemStore() - p.httpClient = &http.Client{} - go p.refreshLoop(30 * time.Millisecond) - t.Cleanup(func() { close(p.stopCh); <-p.stopped }) - - // Seed cache at the limit. - p.mu.Lock() - p.cache["sess"] = &counters{tokens: 100, calls: 3, startedAt: time.Now()} - p.mu.Unlock() - - // Next request exceeds limit — webhook approves. - action := request(p, "sess") - if action.Type != pipeline.Continue { - t.Fatalf("pause mode (approved): expected Continue, got %v", action.Type) - } -} - -// TestE2E_PauseModeDeny verifies webhook denial produces a 403. -func TestE2E_PauseModeDeny(t *testing.T) { - denyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"action":"deny"}`)) - })) - defer denyServer.Close() - - p := New() - cfg, _ := json.Marshal(config{ - RedisURL: "mem://test", - MaxCalls: 3, - OnExceed: "pause", - PauseWebhook: denyServer.URL, - PauseTimeout: "5s", - PauseTimeoutAction: "deny", - RefreshInterval: "30ms", - RedisUnavailable: "fail_open", - }) - if err := p.Configure(cfg); err != nil { - t.Fatalf("Configure: %v", err) - } - p.store = newMemStore() - p.httpClient = &http.Client{} - go p.refreshLoop(30 * time.Millisecond) - t.Cleanup(func() { close(p.stopCh); <-p.stopped }) - - p.mu.Lock() - p.cache["sess"] = &counters{tokens: 100, calls: 3, startedAt: time.Now()} - p.mu.Unlock() - - action := request(p, "sess") - if action.Type != pipeline.Reject { - t.Fatalf("pause mode (denied): expected Reject, got %v", action.Type) + tests := []struct { + name string + response string + want pipeline.ActionType + }{ + {"approve", `{"action":"approve"}`, pipeline.Continue}, + {"deny", `{"action":"deny"}`, pipeline.Reject}, } - status, _, body := action.Violation.Render() - if status != http.StatusForbidden { - t.Errorf("status = %d, want 403", status) - } - var parsed map[string]any - json.Unmarshal(body, &parsed) - if parsed["error"] != "budget.exceeded" { - t.Errorf("error = %v, want budget.exceeded", parsed["error"]) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req map[string]any + json.NewDecoder(r.Body).Decode(&req) + if req["session_id"] != "sess" { + t.Errorf("webhook got session_id=%v, want sess", req["session_id"]) + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(tt.response)) + })) + defer srv.Close() + + p := New() + cfg, _ := json.Marshal(config{ + RedisURL: "mem://test", + MaxCalls: 3, + OnExceed: "pause", + PauseWebhook: srv.URL, + PauseTimeout: "5s", + PauseTimeoutAction: "deny", + RefreshInterval: "30ms", + RedisUnavailable: "fail_open", + }) + if err := p.Configure(cfg); err != nil { + t.Fatalf("Configure: %v", err) + } + p.store = newMemStore() + p.httpClient = &http.Client{} + go p.refreshLoop(30 * time.Millisecond) + t.Cleanup(func() { close(p.stopCh); <-p.stopped }) + + p.mu.Lock() + p.cache["sess"] = &counters{tokens: 100, calls: 3, startedAt: time.Now()} + p.mu.Unlock() + + action := request(p, "sess") + if action.Type != tt.want { + t.Fatalf("action = %v, want %v", action.Type, tt.want) + } + if tt.want == pipeline.Reject { + status, _, body := action.Violation.Render() + if status != http.StatusForbidden { + t.Errorf("status = %d, want 403", status) + } + var parsed map[string]any + json.Unmarshal(body, &parsed) + if parsed["error"] != "budget.exceeded" { + t.Errorf("error = %v, want budget.exceeded", parsed["error"]) + } + } + }) } } diff --git a/authbridge/authlib/plugins/sessionbudget/plugin_test.go b/authbridge/authlib/plugins/sessionbudget/plugin_test.go index e30821a59..179b32546 100644 --- a/authbridge/authlib/plugins/sessionbudget/plugin_test.go +++ b/authbridge/authlib/plugins/sessionbudget/plugin_test.go @@ -464,7 +464,7 @@ func newPausePlugin(t *testing.T, maxCalls int64, webhookURL, timeoutAction stri "max_calls": %d, "on_exceed": "pause", "pause_webhook": %q, - "pause_timeout": "2s", + "pause_timeout": "200ms", "pause_timeout_action": %q, "refresh_interval": "100ms" }`, maxCalls, webhookURL, timeoutAction) @@ -512,76 +512,48 @@ func TestOnRequest_PauseDenied(t *testing.T) { } } -func TestOnRequest_PauseTimeout(t *testing.T) { - done := make(chan struct{}) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - <-done - })) - defer srv.Close() - - p := newPausePlugin(t, 3, srv.URL, "deny") - p.mu.Lock() - p.cache["sess-1"] = &counters{tokens: 0, calls: 3, startedAt: time.Now()} - p.mu.Unlock() - - action := p.OnRequest(context.Background(), makePctx("sess-1", 0)) - close(done) - if action.Type != pipeline.Reject { - t.Fatalf("expected Reject on timeout (pause_timeout_action=deny), got %v", action.Type) +// TestOnRequest_PauseWebhookFailureFallback covers every "webhook doesn't +// return a valid approve" path: timeout, non-200, malformed body. All fall +// back to pause_timeout_action. The 'allow' row also proves the allow +// branch (only place it's exercised). +func TestOnRequest_PauseWebhookFailureFallback(t *testing.T) { + hang := func(done <-chan struct{}) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { <-done } } -} - -func TestOnRequest_PauseTimeoutAllow(t *testing.T) { - done := make(chan struct{}) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - <-done - })) - defer srv.Close() - - p := newPausePlugin(t, 3, srv.URL, "allow") - p.mu.Lock() - p.cache["sess-1"] = &counters{tokens: 0, calls: 3, startedAt: time.Now()} - p.mu.Unlock() - - action := p.OnRequest(context.Background(), makePctx("sess-1", 0)) - close(done) - if action.Type != pipeline.Continue { - t.Fatalf("expected Continue on timeout (pause_timeout_action=allow), got %v", action.Type) - } -} - -func TestOnRequest_PauseWebhookNon200(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - })) - defer srv.Close() - - p := newPausePlugin(t, 3, srv.URL, "deny") - p.mu.Lock() - p.cache["sess-1"] = &counters{tokens: 0, calls: 3, startedAt: time.Now()} - p.mu.Unlock() - - action := p.OnRequest(context.Background(), makePctx("sess-1", 0)) - if action.Type != pipeline.Reject { - t.Fatalf("expected Reject on non-200 webhook (pause_timeout_action=deny), got %v", action.Type) + tests := []struct { + name string + handler http.HandlerFunc + action string + want pipeline.ActionType + }{ + {"timeout_deny", nil, "deny", pipeline.Reject}, + {"timeout_allow", nil, "allow", pipeline.Continue}, + {"non200_deny", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) }, "deny", pipeline.Reject}, + {"badjson_deny", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`not json`)) + }, "deny", pipeline.Reject}, } -} - -func TestOnRequest_PauseWebhookBadJSON(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte(`not json`)) - })) - defer srv.Close() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + done := make(chan struct{}) + h := tt.handler + if h == nil { + h = hang(done) + } + srv := httptest.NewServer(h) + defer func() { close(done); srv.Close() }() - p := newPausePlugin(t, 3, srv.URL, "deny") - p.mu.Lock() - p.cache["sess-1"] = &counters{tokens: 0, calls: 3, startedAt: time.Now()} - p.mu.Unlock() + p := newPausePlugin(t, 3, srv.URL, tt.action) + p.mu.Lock() + p.cache["sess-1"] = &counters{tokens: 0, calls: 3, startedAt: time.Now()} + p.mu.Unlock() - action := p.OnRequest(context.Background(), makePctx("sess-1", 0)) - if action.Type != pipeline.Reject { - t.Fatalf("expected Reject on bad JSON (pause_timeout_action=deny), got %v", action.Type) + got := p.OnRequest(context.Background(), makePctx("sess-1", 0)) + if got.Type != tt.want { + t.Fatalf("action = %v, want %v", got.Type, tt.want) + } + }) } } From 780da7dc26381449c5047570b08ccff69e749b57 Mon Sep 17 00:00:00 2001 From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:46:15 -0600 Subject: [PATCH 11/29] :sparkles: Improve cache refresh and clarify LLM calls Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> --- .../authlib/plugins/sessionbudget/plugin.go | 73 ++++++++++++++----- authbridge/docs/session-budget-plugin.md | 6 +- 2 files changed, 60 insertions(+), 19 deletions(-) diff --git a/authbridge/authlib/plugins/sessionbudget/plugin.go b/authbridge/authlib/plugins/sessionbudget/plugin.go index aba2568bb..0b2bde004 100644 --- a/authbridge/authlib/plugins/sessionbudget/plugin.go +++ b/authbridge/authlib/plugins/sessionbudget/plugin.go @@ -24,7 +24,7 @@ import ( type config struct { RedisURL string `json:"redis_url" required:"true" description:"Redis/Valkey connection URL."` MaxTokens int64 `json:"max_tokens" description:"Cumulative token ceiling per session. 0 = no limit."` - MaxCalls int64 `json:"max_calls" description:"Max inference calls per session. 0 = no limit."` + MaxCalls int64 `json:"max_calls" description:"Max LLM/inference calls per session (counted from inference-parser output; MCP tool calls and other outbound traffic do not count). 0 = no limit."` MaxDurationSeconds int64 `json:"max_duration_seconds" description:"Wall-clock session lifetime in seconds. 0 = no limit."` OnExceed string `json:"on_exceed" description:"Action on breach: deny, observe (shadow), or pause (HITL webhook approval)." default:"deny" enum:"deny,observe,pause"` PauseWebhook string `json:"pause_webhook" description:"URL to POST for approval when on_exceed=pause. Required when on_exceed=pause."` @@ -165,11 +165,12 @@ func (p *SessionBudget) Shutdown(_ context.Context) error { return nil } -// OnRequest evaluates cached counters against limits and optimistically reserves -// a call slot so concurrent requests on the same session see each other. No I/O. +// OnRequest evaluates cached counters against limits. On cold cache the first +// miss hydrates from Redis so pre-existing sessions enforce immediately. func (p *SessionBudget) OnRequest(ctx context.Context, pctx *pipeline.Context) pipeline.Action { sessionID := p.sessionID(pctx) if sessionID == "" { + pctx.Skip("no_session_id") return pipeline.Action{Type: pipeline.Continue} } @@ -177,16 +178,26 @@ func (p *SessionBudget) OnRequest(ctx context.Context, pctx *pipeline.Context) p c, ok := p.cache[sessionID] if !ok { p.mu.Unlock() - // Cold cache: session not yet seen by this pod. First request passes; refresh loop - // picks up Redis counters within one interval. Intentional one-request overshoot - // tradeoff for zero-I/O enforcement on the hot path. - return pipeline.Action{Type: pipeline.Continue} + // Cold cache: try a synchronous hydrate from Redis. If the session + // is already there (e.g. seeded by another pod), we enforce now. + // If Redis is empty or unreachable, we fall through open and Skip. + hydrated := p.hydrateCache(ctx, sessionID) + if !hydrated { + pctx.Skip("cold_cache") + return pipeline.Action{Type: pipeline.Continue} + } + p.mu.Lock() + c, ok = p.cache[sessionID] + if !ok { + p.mu.Unlock() + pctx.Skip("cold_cache") + return pipeline.Action{Type: pipeline.Continue} + } } snap := *c if reason := p.evaluate(&snap); reason != "" { switch p.cfg.OnExceed { case "observe": - c.calls++ p.mu.Unlock() pctx.Observe("shadow_budget_exceeded") p.log.Warn("budget exceeded (shadow mode)", @@ -199,18 +210,17 @@ func (p *SessionBudget) OnRequest(ctx context.Context, pctx *pipeline.Context) p case "pause": // Grace window: skip webhook if recently approved or another request is already waiting. if p.gracePeriod > 0 && !c.lastApprovedAt.IsZero() && time.Since(c.lastApprovedAt) < p.gracePeriod { - c.calls++ p.mu.Unlock() + pctx.Allow("pause_grace_window") return pipeline.Action{Type: pipeline.Continue} } if c.pendingApproval { // Another goroutine is already calling the webhook — piggyback on grace. - c.calls++ p.mu.Unlock() + pctx.Allow("pause_pending_approval") return pipeline.Action{Type: pipeline.Continue} } c.pendingApproval = true - c.calls++ p.mu.Unlock() p.log.Info("budget exceeded, requesting approval", "session", sessionID, @@ -225,21 +235,24 @@ func (p *SessionBudget) OnRequest(ctx context.Context, pctx *pipeline.Context) p } p.mu.Unlock() if approved { + pctx.Allow("pause_approved") return pipeline.Action{Type: pipeline.Continue} } details := p.buildDetails(&snap) + pctx.Record(pipeline.Invocation{Action: pipeline.ActionDeny, Reason: "pause_denied"}) return pipeline.DenyWithDetails("budget.exceeded", reason+" (approval denied)", details) default: // "deny" p.mu.Unlock() details := p.buildDetails(&snap) + pctx.Record(pipeline.Invocation{Action: pipeline.ActionDeny, Reason: "budget_exceeded"}) return pipeline.DenyWithDetails("budget.exceeded", reason, details) } } - // Optimistically reserve a call slot so concurrent requests see it. - c.calls++ p.mu.Unlock() - + // Counts are incremented in OnResponseFrame when inference lands, so + // max_calls only counts LLM/inference calls (see plugin doc). + pctx.Allow("under_budget") return pipeline.Action{Type: pipeline.Continue} } @@ -271,12 +284,11 @@ func (p *SessionBudget) OnResponseFrame(_ context.Context, pctx *pipeline.Contex p.mu.Lock() c, ok := p.cache[sessionID] if !ok { - // First response without a prior OnRequest (e.g. cold cache path). - c = &counters{startedAt: time.Now(), calls: 1} + c = &counters{startedAt: time.Now()} p.cache[sessionID] = c } c.tokens += tokens - // calls already incremented by OnRequest's optimistic reserve. + c.calls++ p.mu.Unlock() return pipeline.Action{Type: pipeline.Continue} @@ -412,6 +424,33 @@ func (p *SessionBudget) refreshLoop(interval time.Duration) { } } +// hydrateCache pulls one session's counters from Redis into the local cache +// so a cold-cache OnRequest miss can enforce immediately. Fail-open on error. +func (p *SessionBudget) hydrateCache(ctx context.Context, sessionID string) bool { + lookupCtx, cancel := context.WithTimeout(ctx, 200*time.Millisecond) + defer cancel() + fields, err := p.store.HashGet(lookupCtx, p.redisKey(sessionID)) + if err != nil { + p.log.Debug("hydrate: redis lookup failed", "session", sessionID, "err", err) + return false + } + if len(fields) == 0 { + return false + } + tokens, _ := strconv.ParseInt(fields["tokens"], 10, 64) + calls, _ := strconv.ParseInt(fields["calls"], 10, 64) + var startedAt time.Time + if ts, err := strconv.ParseInt(fields["started_at"], 10, 64); err == nil { + startedAt = time.Unix(ts, 0) + } + p.mu.Lock() + if _, exists := p.cache[sessionID]; !exists { + p.cache[sessionID] = &counters{tokens: tokens, calls: calls, startedAt: startedAt} + } + p.mu.Unlock() + return true +} + // refreshCache replaces local counters with authoritative Redis values. func (p *SessionBudget) refreshCache() { p.mu.RLock() diff --git a/authbridge/docs/session-budget-plugin.md b/authbridge/docs/session-budget-plugin.md index 1bcc9c8cb..e92583a62 100644 --- a/authbridge/docs/session-budget-plugin.md +++ b/authbridge/docs/session-budget-plugin.md @@ -1,6 +1,8 @@ # session-budget Plugin -Enforces per-session budgets on tokens, inference calls, and wall-clock duration. +Enforces per-session budgets on tokens, LLM/inference calls, and wall-clock +duration. `max_calls` counts inference calls only — MCP tool calls, A2A +messages, and other outbound traffic do not count toward it. Supports three `on_exceed` modes: - `deny` — return 403 (default) @@ -44,7 +46,7 @@ pipeline: |-------|---------|-------------| | `redis_url` | — (required) | Redis/Valkey URL | | `max_tokens` | 0 | Token ceiling (0 = no limit) | -| `max_calls` | 0 | Inference call cap (0 = no limit) | +| `max_calls` | 0 | LLM/inference call cap. Counts calls surfaced by `inference-parser`; MCP, A2A, and other outbound traffic do NOT count. 0 = no limit. | | `max_duration_seconds` | 0 | Session lifetime cap (0 = no limit) | | `on_exceed` | `deny` | `deny` (403), `observe` (log only), or `pause` (webhook) | | `pause_webhook` | — | URL to POST on breach (required when `on_exceed=pause`) | From 22cba1ae565978af14f055ea6144b9fd4541e9dc Mon Sep 17 00:00:00 2001 From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:52:05 -0600 Subject: [PATCH 12/29] :white_check_mark: Test updates for redis read Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> --- .../authlib/plugins/sessionbudget/e2e_test.go | 17 +--- .../plugins/sessionbudget/plugin_test.go | 80 +++---------------- authbridge/docs/session-budget-plugin.md | 2 +- 3 files changed, 15 insertions(+), 84 deletions(-) diff --git a/authbridge/authlib/plugins/sessionbudget/e2e_test.go b/authbridge/authlib/plugins/sessionbudget/e2e_test.go index 3d20cc0ed..f9c90fd01 100644 --- a/authbridge/authlib/plugins/sessionbudget/e2e_test.go +++ b/authbridge/authlib/plugins/sessionbudget/e2e_test.go @@ -182,7 +182,8 @@ func TestE2E_RefreshRecovery(t *testing.T) { } // TestE2E_PodRestart verifies that a fresh plugin with an empty cache -// resumes enforcement after refresh picks up pre-existing store counters. +// enforces on the first request via synchronous Redis hydrate — no +// cold-cache overshoot for sessions already over-budget on Redis. func TestE2E_PodRestart(t *testing.T) { store := newMemStore() p := newE2EPlugin(t, 200, store) @@ -193,19 +194,9 @@ func TestE2E_PodRestart(t *testing.T) { store.HashIncr(ctx, "session-budget:s", "calls", 8) store.HashSetNX(ctx, "session-budget:s", "started_at", "1700000000") - // Cold cache — first request passes (overshoot window). - if a := request(p, "s"); a.Type != pipeline.Continue { - t.Fatalf("cold cache: expected Continue, got %v", a.Type) - } - - // Seed cache entry so refresh discovers this session key. - respond(p, "s", 5) - - // Directly invoke refresh (deterministic, no timing dependency). - p.refreshCache() - + // Cold cache — first request hydrates from Redis and rejects. if a := request(p, "s"); a.Type != pipeline.Reject { - t.Fatalf("after refresh: expected Reject, got %v", a.Type) + t.Fatalf("cold cache with over-budget Redis: expected Reject, got %v", a.Type) } } diff --git a/authbridge/authlib/plugins/sessionbudget/plugin_test.go b/authbridge/authlib/plugins/sessionbudget/plugin_test.go index 179b32546..cc6357879 100644 --- a/authbridge/authlib/plugins/sessionbudget/plugin_test.go +++ b/authbridge/authlib/plugins/sessionbudget/plugin_test.go @@ -369,90 +369,30 @@ func TestOnRequest_ShadowMode(t *testing.T) { t.Fatalf("shadow mode: expected Continue past limit, got %v", action.Type) } - // Verify observe mode still reserves a call slot. + // Calls are counted in OnResponseFrame (2 responses -> 2 calls). + // OnRequest in observe mode does not increment. p.mu.RLock() calls := p.cache["sess-1"].calls p.mu.RUnlock() if calls != 2 { - t.Errorf("calls after shadow OnRequest = %d, want 2 (1 from cold-cache response + 1 observe reservation)", calls) + t.Errorf("calls after shadow OnRequest = %d, want 2 (from 2 OnResponseFrame calls)", calls) } } -func TestOnRequest_OptimisticReservation(t *testing.T) { +// TestOnRequest_RejectsAtCallLimit verifies that once cache reflects +// calls >= max_calls (populated by prior OnResponseFrame or hydrate), +// further OnRequests reject. Calls are counted on inference response, +// not on request, so this test seeds the cache directly. +func TestOnRequest_RejectsAtCallLimit(t *testing.T) { p := newTestPlugin(1000, 10, 0) - // Seed cache so OnRequest finds the session. p.mu.Lock() - p.cache["sess-1"] = &counters{tokens: 50, calls: 7, startedAt: time.Now()} + p.cache["sess-1"] = &counters{tokens: 50, calls: 10, startedAt: time.Now()} p.mu.Unlock() - // Three sequential requests each reserve a slot; calls should reach the limit. - for i := 0; i < 3; i++ { - action := p.OnRequest(context.Background(), makePctx("sess-1", 0)) - if action.Type != pipeline.Continue { - t.Fatalf("request %d: expected Continue, got %v", i+1, action.Type) - } - } - - // Fourth request should be denied (7+3 = 10, limit is 10). action := p.OnRequest(context.Background(), makePctx("sess-1", 0)) if action.Type != pipeline.Reject { - t.Fatalf("request 4: expected Reject at limit, got %v", action.Type) - } - - p.mu.RLock() - calls := p.cache["sess-1"].calls - p.mu.RUnlock() - if calls != 10 { - t.Errorf("calls = %d, want 10", calls) - } -} - -// TestOnRequest_ConcurrentCallLimit verifies that concurrent goroutines racing -// through OnRequest cannot exceed the call limit. This is the scenario the -// RLock→Lock upgrade and optimistic reservation were designed to prevent. -// Distinct from TestOnRequest_OptimisticReservation which tests serial ordering. -func TestOnRequest_ConcurrentCallLimit(t *testing.T) { - p := newTestPlugin(1000, 10, 0) - - p.mu.Lock() - p.cache["sess-1"] = &counters{tokens: 0, calls: 0, startedAt: time.Now()} - p.mu.Unlock() - - const n = 20 - var wg sync.WaitGroup - results := make([]pipeline.ActionType, n) - wg.Add(n) - for i := 0; i < n; i++ { - go func(idx int) { - defer wg.Done() - action := p.OnRequest(context.Background(), makePctx("sess-1", 0)) - results[idx] = action.Type - }(i) - } - wg.Wait() - - var continued, rejected int - for _, r := range results { - switch r { - case pipeline.Continue: - continued++ - case pipeline.Reject: - rejected++ - } - } - if continued != 10 { - t.Errorf("continued = %d, want exactly 10 (call limit)", continued) - } - if rejected != 10 { - t.Errorf("rejected = %d, want 10", rejected) - } - - p.mu.RLock() - calls := p.cache["sess-1"].calls - p.mu.RUnlock() - if calls != 10 { - t.Errorf("final calls = %d, want 10", calls) + t.Fatalf("at limit: expected Reject, got %v", action.Type) } } diff --git a/authbridge/docs/session-budget-plugin.md b/authbridge/docs/session-budget-plugin.md index e92583a62..1dffa8447 100644 --- a/authbridge/docs/session-budget-plugin.md +++ b/authbridge/docs/session-budget-plugin.md @@ -193,7 +193,7 @@ sticky sessions, each pod fires one webhook before its own grace kicks in. |----------|----------| | Redis down at startup | Fail-open until refresh populates cache | | Redis fails mid-session | Local cache keeps enforcing; writes dropped | -| Pod restart | First request passes (cold cache); refresh restores counters within `refresh_interval` | +| Pod restart | First request hydrates from Redis synchronously; enforcement resumes on request #1 | | Webhook unreachable | Falls back to `pause_timeout_action` | Infrastructure failures never produce false denials — Redis unavailability From cdb4b24b0a45eb061cb1940a66e3bdcdc683c397 Mon Sep 17 00:00:00 2001 From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:04:27 -0600 Subject: [PATCH 13/29] :art: Address race condition on hydrate Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> --- authbridge/authlib/go.mod | 2 +- .../authlib/plugins/sessionbudget/plugin.go | 61 ++++++++++--------- 2 files changed, 34 insertions(+), 29 deletions(-) diff --git a/authbridge/authlib/go.mod b/authbridge/authlib/go.mod index a2ca3e191..2e8c24047 100644 --- a/authbridge/authlib/go.mod +++ b/authbridge/authlib/go.mod @@ -13,6 +13,7 @@ require ( github.com/rossoctl/context-guru v0.0.0-20260720181432-8fc7c7b36563 github.com/spiffe/go-spiffe/v2 v2.8.1 golang.org/x/net v0.57.0 + golang.org/x/sync v0.22.0 golang.org/x/sys v0.47.0 google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa google.golang.org/grpc v1.82.0 @@ -117,7 +118,6 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.23.0 // indirect golang.org/x/crypto v0.54.0 // indirect - golang.org/x/sync v0.22.0 // indirect golang.org/x/text v0.40.0 // indirect golang.org/x/time v0.15.0 // indirect google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect diff --git a/authbridge/authlib/plugins/sessionbudget/plugin.go b/authbridge/authlib/plugins/sessionbudget/plugin.go index 0b2bde004..af832f4b0 100644 --- a/authbridge/authlib/plugins/sessionbudget/plugin.go +++ b/authbridge/authlib/plugins/sessionbudget/plugin.go @@ -19,6 +19,7 @@ import ( "github.com/rossoctl/cortex/authbridge/authlib/pipeline" "github.com/rossoctl/cortex/authbridge/authlib/plugins" "github.com/rossoctl/cortex/authbridge/authlib/storage" + "golang.org/x/sync/singleflight" ) type config struct { @@ -53,10 +54,11 @@ type SessionBudget struct { httpClient *http.Client gracePeriod time.Duration - mu sync.RWMutex - cache map[string]*counters - stopCh chan struct{} - stopped chan struct{} + mu sync.RWMutex + cache map[string]*counters + hydrateG singleflight.Group + stopCh chan struct{} + stopped chan struct{} } func New() *SessionBudget { @@ -424,31 +426,34 @@ func (p *SessionBudget) refreshLoop(interval time.Duration) { } } -// hydrateCache pulls one session's counters from Redis into the local cache -// so a cold-cache OnRequest miss can enforce immediately. Fail-open on error. +// hydrateCache pulls one session's counters from Redis on cold-cache miss. +// Concurrent callers for the same session share one Redis lookup via singleflight. func (p *SessionBudget) hydrateCache(ctx context.Context, sessionID string) bool { - lookupCtx, cancel := context.WithTimeout(ctx, 200*time.Millisecond) - defer cancel() - fields, err := p.store.HashGet(lookupCtx, p.redisKey(sessionID)) - if err != nil { - p.log.Debug("hydrate: redis lookup failed", "session", sessionID, "err", err) - return false - } - if len(fields) == 0 { - return false - } - tokens, _ := strconv.ParseInt(fields["tokens"], 10, 64) - calls, _ := strconv.ParseInt(fields["calls"], 10, 64) - var startedAt time.Time - if ts, err := strconv.ParseInt(fields["started_at"], 10, 64); err == nil { - startedAt = time.Unix(ts, 0) - } - p.mu.Lock() - if _, exists := p.cache[sessionID]; !exists { - p.cache[sessionID] = &counters{tokens: tokens, calls: calls, startedAt: startedAt} - } - p.mu.Unlock() - return true + v, _, _ := p.hydrateG.Do(sessionID, func() (any, error) { + lookupCtx, cancel := context.WithTimeout(ctx, 200*time.Millisecond) + defer cancel() + fields, err := p.store.HashGet(lookupCtx, p.redisKey(sessionID)) + if err != nil { + p.log.Debug("hydrate: redis lookup failed", "session", sessionID, "err", err) + return false, nil + } + if len(fields) == 0 { + return false, nil + } + tokens, _ := strconv.ParseInt(fields["tokens"], 10, 64) + calls, _ := strconv.ParseInt(fields["calls"], 10, 64) + var startedAt time.Time + if ts, err := strconv.ParseInt(fields["started_at"], 10, 64); err == nil { + startedAt = time.Unix(ts, 0) + } + p.mu.Lock() + if _, exists := p.cache[sessionID]; !exists { + p.cache[sessionID] = &counters{tokens: tokens, calls: calls, startedAt: startedAt} + } + p.mu.Unlock() + return true, nil + }) + return v.(bool) } // refreshCache replaces local counters with authoritative Redis values. From f4decc185bf2c5f7c649b58c2dc447811501c2bc Mon Sep 17 00:00:00 2001 From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:11:16 -0600 Subject: [PATCH 14/29] :white_check_mark: Update cache hydrate tests Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> --- .../authlib/plugins/sessionbudget/e2e_test.go | 62 ++++++++++++++++--- .../plugins/sessionbudget/plugin_test.go | 37 +---------- 2 files changed, 56 insertions(+), 43 deletions(-) diff --git a/authbridge/authlib/plugins/sessionbudget/e2e_test.go b/authbridge/authlib/plugins/sessionbudget/e2e_test.go index f9c90fd01..b402819fa 100644 --- a/authbridge/authlib/plugins/sessionbudget/e2e_test.go +++ b/authbridge/authlib/plugins/sessionbudget/e2e_test.go @@ -200,16 +200,56 @@ func TestE2E_PodRestart(t *testing.T) { } } -// controllableStore delegates to inner memStore but can be toggled to fail. +// TestE2E_HydrateSingleflight verifies concurrent cold-cache requests +// for the same session share one Redis lookup instead of stampeding. +func TestE2E_HydrateSingleflight(t *testing.T) { + inner := newMemStore() + cs := &controllableStore{inner: inner, hashGetDelay: 50 * time.Millisecond} + p := newE2EPlugin(t, 200, newMemStore()) + p.store = cs + + ctx := context.Background() + inner.HashIncr(ctx, "session-budget:s", "tokens", 210) + inner.HashIncr(ctx, "session-budget:s", "calls", 8) + inner.HashSetNX(ctx, "session-budget:s", "started_at", "1700000000") + + const N = 20 + var wg sync.WaitGroup + wg.Add(N) + for i := 0; i < N; i++ { + go func() { + defer wg.Done() + if a := request(p, "s"); a.Type != pipeline.Reject { + t.Errorf("expected Reject, got %v", a.Type) + } + }() + } + wg.Wait() + + // Without singleflight all N calls would hit HashGet. With it, only the + // first flight does — later arrivals see the populated cache and skip hydrate. + // Allow a small slack for goroutines that raced past the cache-check before + // the first flight populated it. + got := cs.hashGetCalls() + if got > 3 { + t.Errorf("HashGet called %d times for %d concurrent cold-cache requests; expected ≤3 (singleflight dedup)", got, N) + } +} + +// controllableStore delegates to inner memStore but can be toggled to fail, +// counts HashGet calls, and optionally injects latency into HashGet. type controllableStore struct { - inner *memStore - failing bool - mu sync.Mutex + inner *memStore + failing bool + hashGetDelay time.Duration + mu sync.Mutex + hashGets int } -func (c *controllableStore) setFailing(v bool) { c.mu.Lock(); c.failing = v; c.mu.Unlock() } -func (c *controllableStore) isFailing() bool { c.mu.Lock(); defer c.mu.Unlock(); return c.failing } -func (c *controllableStore) err() error { return context.DeadlineExceeded } +func (c *controllableStore) setFailing(v bool) { c.mu.Lock(); c.failing = v; c.mu.Unlock() } +func (c *controllableStore) isFailing() bool { c.mu.Lock(); defer c.mu.Unlock(); return c.failing } +func (c *controllableStore) hashGetCalls() int { c.mu.Lock(); defer c.mu.Unlock(); return c.hashGets } +func (c *controllableStore) err() error { return context.DeadlineExceeded } func (c *controllableStore) Get(ctx context.Context, key string) (string, error) { if c.isFailing() { return "", c.err() } @@ -228,7 +268,13 @@ func (c *controllableStore) HashIncr(ctx context.Context, key, field string, del return c.inner.HashIncr(ctx, key, field, delta) } func (c *controllableStore) HashGet(ctx context.Context, key string) (map[string]string, error) { - if c.isFailing() { return nil, c.err() } + c.mu.Lock() + c.hashGets++ + delay := c.hashGetDelay + failing := c.failing + c.mu.Unlock() + if delay > 0 { time.Sleep(delay) } + if failing { return nil, c.err() } return c.inner.HashGet(ctx, key) } func (c *controllableStore) HashSetNX(ctx context.Context, key, field, value string) (bool, error) { diff --git a/authbridge/authlib/plugins/sessionbudget/plugin_test.go b/authbridge/authlib/plugins/sessionbudget/plugin_test.go index cc6357879..2d1ba132b 100644 --- a/authbridge/authlib/plugins/sessionbudget/plugin_test.go +++ b/authbridge/authlib/plugins/sessionbudget/plugin_test.go @@ -416,41 +416,8 @@ func newPausePlugin(t *testing.T, maxCalls int64, webhookURL, timeoutAction stri return p } -func TestOnRequest_PauseApproved(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"action":"approve"}`)) - })) - defer srv.Close() - - p := newPausePlugin(t, 3, srv.URL, "deny") - p.mu.Lock() - p.cache["sess-1"] = &counters{tokens: 0, calls: 3, startedAt: time.Now()} - p.mu.Unlock() - - action := p.OnRequest(context.Background(), makePctx("sess-1", 0)) - if action.Type != pipeline.Continue { - t.Fatalf("expected Continue after approval, got %v", action.Type) - } -} - -func TestOnRequest_PauseDenied(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"action":"deny"}`)) - })) - defer srv.Close() - - p := newPausePlugin(t, 3, srv.URL, "deny") - p.mu.Lock() - p.cache["sess-1"] = &counters{tokens: 0, calls: 3, startedAt: time.Now()} - p.mu.Unlock() - - action := p.OnRequest(context.Background(), makePctx("sess-1", 0)) - if action.Type != pipeline.Reject { - t.Fatalf("expected Reject after denial, got %v", action.Type) - } -} +// Approve / deny happy paths are covered by TestE2E_PauseMode in e2e_test.go +// (table-driven, asserts webhook request body + 403 response schema). // TestOnRequest_PauseWebhookFailureFallback covers every "webhook doesn't // return a valid approve" path: timeout, non-200, malformed body. All fall From c8f61e0ec21c280c6d9f34c5b83972a93c444e69 Mon Sep 17 00:00:00 2001 From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> Date: Tue, 18 Aug 2026 09:40:54 -0600 Subject: [PATCH 15/29] :memo: Add cluster ambient-mesh details Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> --- authbridge/demos/session-budget/README.md | 59 +++++++++++++++---- .../k8s/pause-webhook-stub.yaml | 6 ++ authbridge/docs/session-budget-plugin.md | 25 ++++++++ 3 files changed, 79 insertions(+), 11 deletions(-) diff --git a/authbridge/demos/session-budget/README.md b/authbridge/demos/session-budget/README.md index c8f2c8133..ca196e5aa 100644 --- a/authbridge/demos/session-budget/README.md +++ b/authbridge/demos/session-budget/README.md @@ -14,25 +14,62 @@ body so you can see exactly what session-budget sent. kubectl apply -f k8s/pause-webhook-stub.yaml ``` -Then point the plugin at it: +To exercise the deny path, edit the inline Python in the manifest to return +`{"action":"deny"}` and re-apply. + +Follow the webhook stub: + +```bash +kubectl logs -n team1 deploy/pause-webhook-stub -f +``` + +## Prerequisites + +- **A Redis-wire-compatible store** reachable from the agent pod. Any + Valkey/Redis deployment works; point `redis_url` at its Service. +- **An agent with `jwt-validation` and (optionally) `token-exchange` + configured on its authbridge sidecar.** These are shared across every + authbridge demo — see the + [weather-agent demo](../weather-agent/) for the standard inbound + validation setup, and + [token-exchange-routes](../token-exchange-routes/) for outbound + route configuration. This demo assumes those are already in place and + focuses on adding `session-budget` to the outbound pipeline. + +**Ambient-mesh note:** if your namespace has +`istio.io/dataplane-mode: ambient`, the datastore pod needs the pod-level +label `istio.io/dataplane-mode: none`. Ambient's ztunnel drops non-HBONE +connections with `Connection reset by peer`, and Redis RESP is raw TCP — +it can't ride HBONE. The pause webhook stub manifest already carries the +exemption. + +## Configuring the plugin ```yaml pipeline: + inbound: + plugins: + - name: jwt-validation + config: { ... } + - name: a2a-parser # REQUIRED — parses contextId → Session.ID outbound: plugins: + - name: token-exchange + config: { ... } - name: session-budget config: - redis_url: "redis://valkey.infra.svc:6379" - max_calls: 5 + redis_url: "redis://valkey.team1.svc:6379" + max_calls: 3 + max_duration_seconds: 1800 on_exceed: pause pause_webhook: "http://pause-webhook-stub.team1.svc.cluster.local" + pause_timeout: 10s + pause_timeout_action: deny + pause_grace_period: 5m + - name: inference-parser ``` -Follow the webhook logs: - -```bash -kubectl logs -n team1 deploy/pause-webhook-stub -f -``` - -To exercise the deny path, edit the inline Python in the manifest to write -`{"action":"deny"}` and re-apply. +**`a2a-parser` on inbound is not optional.** Without it, every request lands +in the `default` session bucket (no `Rekey` from `contextId`), so +session-budget can never distinguish sessions and cold-cache hydrate looks +for the wrong key. diff --git a/authbridge/demos/session-budget/k8s/pause-webhook-stub.yaml b/authbridge/demos/session-budget/k8s/pause-webhook-stub.yaml index 196b53390..d67786f83 100644 --- a/authbridge/demos/session-budget/k8s/pause-webhook-stub.yaml +++ b/authbridge/demos/session-budget/k8s/pause-webhook-stub.yaml @@ -27,6 +27,12 @@ spec: metadata: labels: app: pause-webhook-stub + # Opt out of ambient mesh: the plain HTTP stub isn't part of the + # authbridge zero-trust boundary and ambient interception drops + # non-HBONE peers with 'Connection reset'. + istio.io/dataplane-mode: none + annotations: + ambient.istio.io/redirection: disabled spec: containers: - name: stub diff --git a/authbridge/docs/session-budget-plugin.md b/authbridge/docs/session-budget-plugin.md index 1dffa8447..3f211ec72 100644 --- a/authbridge/docs/session-budget-plugin.md +++ b/authbridge/docs/session-budget-plugin.md @@ -242,6 +242,31 @@ http.server.HTTPServer(('',8888),h).serve_forever()" Swap `approve` for `deny` to test the reject path. Logs land in `docker logs pause-webhook`. +## In-cluster deployment note + +**If your namespace runs Istio ambient mesh** (label +`istio.io/dataplane-mode: ambient`), the Valkey pod and any plain-HTTP +pause webhook need to opt out with the pod-level label +`istio.io/dataplane-mode: none`. Here's why: + +Ambient mesh puts a per-node proxy (ztunnel) in front of every enrolled +pod. Traffic between enrolled pods is wrapped in **HBONE** — HTTP/2 +CONNECT tunnels with mTLS between ztunnels. The destination ztunnel +only accepts HBONE; anything else gets closed at L4 with +`Connection reset by peer`. + +Redis (and Valkey) speaks its own binary protocol (RESP) directly over +TCP — it can't be tunneled inside HTTP/2, so ambient's ztunnel rejects +the connection before it ever reaches Valkey. Same story for any +plain-TCP or plain-HTTP service you don't want mesh-managed. Opting +those pods out of ambient makes them normal Kubernetes pods again, so +callers reach them over plain TCP. + +Symptom if you forget: `session-budget action=skip reason=cold_cache` +on every request even though `HGETALL session-budget:` in Redis +shows the key — the plugin's Redis lookup is being closed by ztunnel +before it reaches Valkey. + For an in-cluster stub, apply `authbridge/demos/session-budget/k8s/pause-webhook-stub.yaml` and set `pause_webhook: http://pause-webhook-stub.team1.svc.cluster.local`. See From 8292b77abd2c1f8a8674bcdf22f857bc9e5ed13a Mon Sep 17 00:00:00 2001 From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> Date: Tue, 18 Aug 2026 10:54:08 -0600 Subject: [PATCH 16/29] :recycle::white_check_mark: Keep deny/observe cold-cache skip behavior Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> --- .../authlib/plugins/sessionbudget/e2e_test.go | 105 +++++++++--------- .../authlib/plugins/sessionbudget/plugin.go | 35 ++++-- .../plugins/sessionbudget/plugin_test.go | 48 ++++++++ authbridge/demos/session-budget/README.md | 34 +++--- authbridge/docs/session-budget-plugin.md | 33 ++++-- 5 files changed, 166 insertions(+), 89 deletions(-) diff --git a/authbridge/authlib/plugins/sessionbudget/e2e_test.go b/authbridge/authlib/plugins/sessionbudget/e2e_test.go index b402819fa..1dd102e12 100644 --- a/authbridge/authlib/plugins/sessionbudget/e2e_test.go +++ b/authbridge/authlib/plugins/sessionbudget/e2e_test.go @@ -35,6 +35,33 @@ func newE2EPlugin(t *testing.T, maxTokens int64, store *memStore) *SessionBudget return p } +// newE2EPluginPause builds a plugin in on_exceed=pause mode so cold-cache +// hydrate runs on the OnRequest path (see plugin.go). The caller supplies a +// webhook URL — pass a deny-returning stub if you want breaches to reject. +func newE2EPluginPause(t *testing.T, maxTokens int64, store *memStore, webhookURL string) *SessionBudget { + t.Helper() + p := New() + cfg, _ := json.Marshal(config{ + RedisURL: "mem://test", + MaxTokens: maxTokens, + OnExceed: "pause", + PauseWebhook: webhookURL, + PauseTimeout: "2s", + PauseTimeoutAction: "deny", + PauseGracePeriod: "0s", + RefreshInterval: "30ms", + RedisUnavailable: "fail_open", + }) + if err := p.Configure(cfg); err != nil { + t.Fatalf("Configure: %v", err) + } + p.store = store + p.httpClient = &http.Client{Timeout: 0} + go p.refreshLoop(30 * time.Millisecond) + t.Cleanup(func() { close(p.stopCh); <-p.stopped }) + return p +} + func respond(p *SessionBudget, sessionID string, tokens int) { p.OnResponseFrame(context.Background(), makePctx(sessionID, tokens), nil, true) } @@ -182,11 +209,19 @@ func TestE2E_RefreshRecovery(t *testing.T) { } // TestE2E_PodRestart verifies that a fresh plugin with an empty cache -// enforces on the first request via synchronous Redis hydrate — no -// cold-cache overshoot for sessions already over-budget on Redis. +// hydrates from Redis on the first request in pause mode — no cold-cache +// overshoot for sessions already over-budget on Redis. Only pause mode +// hydrates on the request path (see plugin.go OnRequest); deny and observe +// intentionally skip on cold cache and let OnResponseFrame + the refresh +// loop populate counters, at the cost of a one-request-per-pod overshoot +// for pre-existing sessions. func TestE2E_PodRestart(t *testing.T) { store := newMemStore() - p := newE2EPlugin(t, 200, store) + webhook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"action":"deny"}`)) + })) + defer webhook.Close() + p := newE2EPluginPause(t, 200, store, webhook.URL) ctx := context.Background() // Pre-seed Redis above the limit (210 > 200). @@ -194,7 +229,8 @@ func TestE2E_PodRestart(t *testing.T) { store.HashIncr(ctx, "session-budget:s", "calls", 8) store.HashSetNX(ctx, "session-budget:s", "started_at", "1700000000") - // Cold cache — first request hydrates from Redis and rejects. + // Cold cache — first request hydrates from Redis, fires the webhook, + // gets a deny, and rejects. if a := request(p, "s"); a.Type != pipeline.Reject { t.Fatalf("cold cache with over-budget Redis: expected Reject, got %v", a.Type) } @@ -202,10 +238,16 @@ func TestE2E_PodRestart(t *testing.T) { // TestE2E_HydrateSingleflight verifies concurrent cold-cache requests // for the same session share one Redis lookup instead of stampeding. +// Uses pause mode because that's the only mode where OnRequest hydrates +// (deny/observe intentionally skip cold-cache to keep Redis off the hot path). func TestE2E_HydrateSingleflight(t *testing.T) { inner := newMemStore() cs := &controllableStore{inner: inner, hashGetDelay: 50 * time.Millisecond} - p := newE2EPlugin(t, 200, newMemStore()) + webhook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"action":"deny"}`)) + })) + defer webhook.Close() + p := newE2EPluginPause(t, 200, newMemStore(), webhook.URL) p.store = cs ctx := context.Background() @@ -213,15 +255,17 @@ func TestE2E_HydrateSingleflight(t *testing.T) { inner.HashIncr(ctx, "session-budget:s", "calls", 8) inner.HashSetNX(ctx, "session-budget:s", "started_at", "1700000000") + // This test asserts singleflight dedup on hydrate — it counts HashGet + // calls, not per-request outcomes. Pause-mode concurrent breaches + // piggyback on pendingApproval (one leader gets Reject, others continue + // via pause_pending_approval), so we don't assert per-request Reject. const N = 20 var wg sync.WaitGroup wg.Add(N) for i := 0; i < N; i++ { go func() { defer wg.Done() - if a := request(p, "s"); a.Type != pipeline.Reject { - t.Errorf("expected Reject, got %v", a.Type) - } + _ = request(p, "s") }() } wg.Wait() @@ -287,51 +331,6 @@ func (c *controllableStore) Expire(ctx context.Context, key string, ttl time.Dur } func (c *controllableStore) Close() error { return nil } -// TestE2E_ShadowMode verifies that on_exceed=observe allows requests -// through even when budget is exceeded, while still accumulating. -func TestE2E_ShadowMode(t *testing.T) { - p := New() - cfg, _ := json.Marshal(config{ - RedisURL: "mem://test", - MaxTokens: 150, - OnExceed: "observe", - RefreshInterval: "30ms", - RedisUnavailable: "fail_open", - }) - if err := p.Configure(cfg); err != nil { - t.Fatalf("Configure: %v", err) - } - store := newMemStore() - p.store = store - go p.refreshLoop(30 * time.Millisecond) - t.Cleanup(func() { close(p.stopCh); <-p.stopped }) - - for i := 0; i < 3; i++ { - respond(p, "sess", 60) // 180 total > 150 limit - } - - // In observe mode, request should continue (not reject). - action := request(p, "sess") - if action.Type != pipeline.Continue { - t.Fatalf("shadow mode: expected Continue, got %v", action.Type) - } - - // Counters should still accumulate past the limit. - respond(p, "sess", 20) // 200 total - p.mu.RLock() - c := p.cache["sess"] - p.mu.RUnlock() - if c.tokens != 200 { - t.Errorf("tokens = %d, want 200 (accumulation continues in shadow mode)", c.tokens) - } - - // Subsequent requests also continue. - action = request(p, "sess") - if action.Type != pipeline.Continue { - t.Fatalf("shadow mode (2nd request): expected Continue, got %v", action.Type) - } -} - // TestE2E_PauseMode covers the full lifecycle for both webhook outcomes. // The 'approve' row also verifies the request body carries session_id; // the 'deny' row also verifies the 403 response schema. diff --git a/authbridge/authlib/plugins/sessionbudget/plugin.go b/authbridge/authlib/plugins/sessionbudget/plugin.go index af832f4b0..883ebeb8f 100644 --- a/authbridge/authlib/plugins/sessionbudget/plugin.go +++ b/authbridge/authlib/plugins/sessionbudget/plugin.go @@ -180,18 +180,29 @@ func (p *SessionBudget) OnRequest(ctx context.Context, pctx *pipeline.Context) p c, ok := p.cache[sessionID] if !ok { p.mu.Unlock() - // Cold cache: try a synchronous hydrate from Redis. If the session - // is already there (e.g. seeded by another pod), we enforce now. - // If Redis is empty or unreachable, we fall through open and Skip. - hydrated := p.hydrateCache(ctx, sessionID) - if !hydrated { - pctx.Skip("cold_cache") - return pipeline.Action{Type: pipeline.Continue} - } - p.mu.Lock() - c, ok = p.cache[sessionID] - if !ok { - p.mu.Unlock() + // Cold cache handling is mode-dependent: + // - pause: synchronously hydrate from Redis so pre-existing sessions + // (seeded by another pod) fire the webhook on request #1. Pause is + // the only mode where a one-request-per-pod overshoot would defeat + // the point — HITL only works if we ask before continuing. + // - deny / observe: skip with cold_cache. The local counters populate + // via OnResponseFrame + the background refresh loop; a single pod + // may under-enforce by up to one request for a pre-existing session, + // which is the same tradeoff these modes have always had. This + // avoids putting Redis on the request path for the common modes. + if p.cfg.OnExceed == "pause" { + if !p.hydrateCache(ctx, sessionID) { + pctx.Skip("cold_cache") + return pipeline.Action{Type: pipeline.Continue} + } + p.mu.Lock() + c, ok = p.cache[sessionID] + if !ok { + p.mu.Unlock() + pctx.Skip("cold_cache") + return pipeline.Action{Type: pipeline.Continue} + } + } else { pctx.Skip("cold_cache") return pipeline.Action{Type: pipeline.Continue} } diff --git a/authbridge/authlib/plugins/sessionbudget/plugin_test.go b/authbridge/authlib/plugins/sessionbudget/plugin_test.go index 2d1ba132b..a2dfb9cb1 100644 --- a/authbridge/authlib/plugins/sessionbudget/plugin_test.go +++ b/authbridge/authlib/plugins/sessionbudget/plugin_test.go @@ -377,6 +377,18 @@ func TestOnRequest_ShadowMode(t *testing.T) { if calls != 2 { t.Errorf("calls after shadow OnRequest = %d, want 2 (from 2 OnResponseFrame calls)", calls) } + + // Accumulation continues past the limit — observe never blocks writes. + p.OnResponseFrame(context.Background(), makePctx("sess-1", 60), nil, true) + p.mu.RLock() + tokens := p.cache["sess-1"].tokens + p.mu.RUnlock() + if tokens != 180 { + t.Errorf("tokens after post-limit response = %d, want 180", tokens) + } + if a := p.OnRequest(context.Background(), makePctx("sess-1", 0)); a.Type != pipeline.Continue { + t.Fatalf("shadow mode (2nd request past limit): expected Continue, got %v", a.Type) + } } // TestOnRequest_RejectsAtCallLimit verifies that once cache reflects @@ -668,6 +680,42 @@ func TestOnRequest_PausePendingApprovalSentinel(t *testing.T) { } } +// TestOnRequest_ColdCacheModeGating pins the documented contract: cold-cache +// hydrates from Redis on the OnRequest path only in pause mode. deny and +// observe skip cold-cache and let counters populate via OnResponseFrame + +// refresh loop — accepting one-request-per-pod overshoot for pre-existing +// over-budget sessions. See docs/session-budget-plugin.md "Cold-cache +// behavior". +func TestOnRequest_ColdCacheModeGating(t *testing.T) { + for _, mode := range []string{"deny", "observe"} { + t.Run(mode, func(t *testing.T) { + p := New() + cfg, _ := json.Marshal(config{ + RedisURL: "mem://test", + MaxTokens: 100, + OnExceed: mode, + RefreshInterval: "1s", + RedisUnavailable: "fail_open", + }) + if err := p.Configure(cfg); err != nil { + t.Fatalf("Configure: %v", err) + } + // Pre-seed Redis above budget. If cold-cache hydrated, evaluate() + // would fire. deny/observe must NOT hydrate — request continues. + store := newMemStore() + ctx := context.Background() + store.HashIncr(ctx, "session-budget:s", "tokens", 500) + store.HashIncr(ctx, "session-budget:s", "calls", 9) + p.store = store + + a := p.OnRequest(ctx, makePctx("s", 0)) + if a.Type != pipeline.Continue { + t.Fatalf("%s cold cache: expected Continue (no hydrate on request path), got %v", mode, a.Type) + } + }) + } +} + func TestEvaluate_MultipleLimits(t *testing.T) { p := newTestPlugin(100, 10, 60) diff --git a/authbridge/demos/session-budget/README.md b/authbridge/demos/session-budget/README.md index ca196e5aa..5c2e209db 100644 --- a/authbridge/demos/session-budget/README.md +++ b/authbridge/demos/session-budget/README.md @@ -6,16 +6,16 @@ configuration and mode semantics, see ## `k8s/pause-webhook-stub.yaml` -Minimal HITL webhook that returns `{"action":"approve"}` for every POST — enough -to smoke-test `on_exceed: pause` end-to-end. Also logs each incoming request -body so you can see exactly what session-budget sent. +Minimal HITL webhook that returns `{"action":"approve"}` for every POST — +enough to smoke-test `on_exceed: pause` end-to-end. Also logs each +incoming request body so you can see exactly what session-budget sent. ```bash kubectl apply -f k8s/pause-webhook-stub.yaml ``` -To exercise the deny path, edit the inline Python in the manifest to return -`{"action":"deny"}` and re-apply. +To exercise the deny path, edit the inline Python in the manifest to +return `{"action":"deny"}` and re-apply. Follow the webhook stub: @@ -32,16 +32,16 @@ kubectl logs -n team1 deploy/pause-webhook-stub -f authbridge demo — see the [weather-agent demo](../weather-agent/) for the standard inbound validation setup, and - [token-exchange-routes](../token-exchange-routes/) for outbound - route configuration. This demo assumes those are already in place and - focuses on adding `session-budget` to the outbound pipeline. + [token-exchange-routes](../token-exchange-routes/) for outbound route + configuration. This demo assumes those are already in place and focuses + on adding `session-budget` to the outbound pipeline. **Ambient-mesh note:** if your namespace has -`istio.io/dataplane-mode: ambient`, the datastore pod needs the pod-level -label `istio.io/dataplane-mode: none`. Ambient's ztunnel drops non-HBONE -connections with `Connection reset by peer`, and Redis RESP is raw TCP — -it can't ride HBONE. The pause webhook stub manifest already carries the -exemption. +`istio.io/dataplane-mode: ambient`, the datastore pod needs the +pod-level label `istio.io/dataplane-mode: none`. Ambient's ztunnel drops +non-HBONE connections with `Connection reset by peer`, and Redis RESP is +raw TCP — it can't ride HBONE. The pause webhook stub manifest already +carries the exemption. ## Configuring the plugin @@ -69,7 +69,7 @@ pipeline: - name: inference-parser ``` -**`a2a-parser` on inbound is not optional.** Without it, every request lands -in the `default` session bucket (no `Rekey` from `contextId`), so -session-budget can never distinguish sessions and cold-cache hydrate looks -for the wrong key. +**`a2a-parser` on inbound is not optional.** Without it, every request +lands in the `default` session bucket (no `Rekey` from `contextId`), so +session-budget can never distinguish sessions and cold-cache hydrate +looks for the wrong key. diff --git a/authbridge/docs/session-budget-plugin.md b/authbridge/docs/session-budget-plugin.md index 3f211ec72..cd9a1ad34 100644 --- a/authbridge/docs/session-budget-plugin.md +++ b/authbridge/docs/session-budget-plugin.md @@ -7,11 +7,11 @@ Supports three `on_exceed` modes: - `deny` — return 403 (default) - `observe` — shadow mode: log without blocking, useful for calibrating limits -- `pause` — HITL: POST to a webhook for human/system approval before continuing +- `pause` — HITL: POST to a webhook for approval before continuing -A "session" is the AuthBridge session ID (typically one A2A conversation or agent -task invocation). Redis holds durable counters across pods; a local cache serves -the hot path with zero I/O. +A "session" is the AuthBridge session ID (typically one A2A conversation +or agent task invocation). Redis holds durable counters across pods; a +local cache serves the hot path with zero I/O. ## Build @@ -95,7 +95,8 @@ calibrate limits before enforcing: 1. Deploy with `on_exceed: observe` and conservative limits. 2. Watch logs for shadow-mode entries. -3. Adjust `max_tokens` / `max_calls` / `max_duration_seconds` to fit real workloads. +3. Adjust `max_tokens` / `max_calls` / `max_duration_seconds` to fit real + workloads. 4. Flip to `on_exceed: deny` (or `pause`) once confident. ### `pause` (HITL webhook) @@ -193,9 +194,23 @@ sticky sessions, each pod fires one webhook before its own grace kicks in. |----------|----------| | Redis down at startup | Fail-open until refresh populates cache | | Redis fails mid-session | Local cache keeps enforcing; writes dropped | -| Pod restart | First request hydrates from Redis synchronously; enforcement resumes on request #1 | +| Pod restart, `pause` | Request #1 hydrates from Redis synchronously | +| Pod restart, `deny` / `observe` | Request #1 skips (`cold_cache`); see below | | Webhook unreachable | Falls back to `pause_timeout_action` | +### Cold-cache behavior + +Cold-cache handling on `OnRequest` is mode-dependent: + +- **`pause`** — synchronously hydrates from Redis before evaluating, so a + session already over-budget on Redis fires the webhook on request #1. + HITL only works if we ask before continuing. +- **`deny` / `observe`** — skip with `reason=cold_cache` and continue. + Counters populate via `OnResponseFrame` and the refresh loop. A + pre-existing over-budget session may pass **up to one request per pod** + before enforcement resumes — the same tradeoff these modes have always + had. Keeps Redis off the hot path. + Infrastructure failures never produce false denials — Redis unavailability degrades enforcement to local-cache-only (no cross-pod consistency) rather than blocking requests. @@ -247,7 +262,11 @@ Swap `approve` for `deny` to test the reject path. Logs land in **If your namespace runs Istio ambient mesh** (label `istio.io/dataplane-mode: ambient`), the Valkey pod and any plain-HTTP pause webhook need to opt out with the pod-level label -`istio.io/dataplane-mode: none`. Here's why: +`istio.io/dataplane-mode: none`. This matters most for `on_exceed: pause`, +which puts a synchronous Redis lookup on the request path (see +"Cold-cache behavior"); deny/observe tolerate Redis being unreachable on +the hot path but still need it for cross-pod counter sync via the refresh +loop. Here's why: Ambient mesh puts a per-node proxy (ztunnel) in front of every enrolled pod. Traffic between enrolled pods is wrapped in **HBONE** — HTTP/2 From ebc8811b76dffa1c350b07b8fdca45d32b1f126a Mon Sep 17 00:00:00 2001 From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:05:02 -0600 Subject: [PATCH 17/29] :memo: Calls clarification Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> --- authbridge/docs/session-budget-plugin.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/authbridge/docs/session-budget-plugin.md b/authbridge/docs/session-budget-plugin.md index cd9a1ad34..10976f05b 100644 --- a/authbridge/docs/session-budget-plugin.md +++ b/authbridge/docs/session-budget-plugin.md @@ -46,7 +46,7 @@ pipeline: |-------|---------|-------------| | `redis_url` | — (required) | Redis/Valkey URL | | `max_tokens` | 0 | Token ceiling (0 = no limit) | -| `max_calls` | 0 | LLM/inference call cap. Counts calls surfaced by `inference-parser`; MCP, A2A, and other outbound traffic do NOT count. 0 = no limit. | +| `max_calls` | 0 | LLM/inference call cap. Counts calls surfaced by `inference-parser`; MCP, A2A, and other outbound traffic do NOT count toward the counter. 0 = no limit. **The limit check runs on every outbound request**, though — once the LLM counter crosses `max_calls`, the next outbound of any kind (MCP tool call, A2A message, etc.) is the one that gets the 403. | | `max_duration_seconds` | 0 | Session lifetime cap (0 = no limit) | | `on_exceed` | `deny` | `deny` (403), `observe` (log only), or `pause` (webhook) | | `pause_webhook` | — | URL to POST on breach (required when `on_exceed=pause`) | From 8c20809196cf73d23c5e55a535a1d8931688f55f Mon Sep 17 00:00:00 2001 From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:13:52 -0600 Subject: [PATCH 18/29] :memo: Pause mode user directions Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> --- authbridge/demos/session-budget/README.md | 41 +++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/authbridge/demos/session-budget/README.md b/authbridge/demos/session-budget/README.md index 5c2e209db..9b537cc0f 100644 --- a/authbridge/demos/session-budget/README.md +++ b/authbridge/demos/session-budget/README.md @@ -73,3 +73,44 @@ pipeline: lands in the `default` session bucket (no `Rekey` from `contextId`), so session-budget can never distinguish sessions and cold-cache hydrate looks for the wrong key. + +## Try it end-to-end (pause mode) + +Assumes an authbridge-sidecar'd agent is already running in `${NS}` with +`session-budget` (pause mode) + `a2a-parser` inbound configured per +above. Substitute your own agent, session id, and A2A payload. + +```bash +NS=team1 +AGENT_POD=$(kubectl -n $NS get pod -l app.kubernetes.io/name= \ + -o jsonpath='{.items[0].metadata.name}') +SESSION=demo-$RANDOM + +# 1. Seed Redis so this session is already over budget. +kubectl -n $NS exec valkey -- valkey-cli HSET \ + session-budget:$SESSION calls 99 tokens 0 started_at $(date +%s) + +# 2. Fire one A2A request with contextId = seeded session. +# (Any callable agent works; adjust auth + payload to fit yours.) +kubectl -n $NS port-forward pod/$AGENT_POD 8000:8000 & +curl -sS -X POST http://localhost:8000/ \ + -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":"1","method":"message/send","params":{ + "message":{"messageId":"m1","role":"user", + "parts":[{"kind":"text","text":"hi"}], + "contextId":"'$SESSION'"}}}' + +# 3. Confirm the webhook was called with the right session_id. +kubectl -n $NS logs deploy/pause-webhook-stub | grep $SESSION +``` + +Expected: the request returns 200 (stub approves), and the webhook log +shows one POST body with `"session_id":""` and `"reason": +"call limit reached: ..."`. + +**Try the other modes:** swap `on_exceed: pause` for `deny` or +`observe` in the plugin config, redeploy, and repeat step 2. `deny` +returns 403 with a `budget.exceeded` body once the local cache catches +up (one request may pass first — see the cold-cache note in the +reference doc). `observe` never blocks; grep the authbridge-proxy logs +for `"budget exceeded (shadow mode)"` to see breaches. From 129b36963fc0efd967842fee4b2b187f5ddaacfe Mon Sep 17 00:00:00 2001 From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:32:53 -0600 Subject: [PATCH 19/29] :bug::memo: Update prereq details Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> --- authbridge/demos/session-budget/README.md | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/authbridge/demos/session-budget/README.md b/authbridge/demos/session-budget/README.md index 9b537cc0f..b90beb9d3 100644 --- a/authbridge/demos/session-budget/README.md +++ b/authbridge/demos/session-budget/README.md @@ -27,14 +27,7 @@ kubectl logs -n team1 deploy/pause-webhook-stub -f - **A Redis-wire-compatible store** reachable from the agent pod. Any Valkey/Redis deployment works; point `redis_url` at its Service. -- **An agent with `jwt-validation` and (optionally) `token-exchange` - configured on its authbridge sidecar.** These are shared across every - authbridge demo — see the - [weather-agent demo](../weather-agent/) for the standard inbound - validation setup, and - [token-exchange-routes](../token-exchange-routes/) for outbound route - configuration. This demo assumes those are already in place and focuses - on adding `session-budget` to the outbound pipeline. +- **`a2a-parser` on the inbound pipeline** — see the note below. **Ambient-mesh note:** if your namespace has `istio.io/dataplane-mode: ambient`, the datastore pod needs the @@ -45,17 +38,15 @@ carries the exemption. ## Configuring the plugin +Minimum pipeline for session-budget: + ```yaml pipeline: inbound: plugins: - - name: jwt-validation - config: { ... } - name: a2a-parser # REQUIRED — parses contextId → Session.ID outbound: plugins: - - name: token-exchange - config: { ... } - name: session-budget config: redis_url: "redis://valkey.team1.svc:6379" @@ -66,7 +57,7 @@ pipeline: pause_timeout: 10s pause_timeout_action: deny pause_grace_period: 5m - - name: inference-parser + - name: inference-parser # supplies token counts to session-budget ``` **`a2a-parser` on inbound is not optional.** Without it, every request @@ -88,7 +79,7 @@ SESSION=demo-$RANDOM # 1. Seed Redis so this session is already over budget. kubectl -n $NS exec valkey -- valkey-cli HSET \ - session-budget:$SESSION calls 99 tokens 0 started_at $(date +%s) + session-budget:$SESSION calls 99 started_at $(date +%s) # 2. Fire one A2A request with contextId = seeded session. # (Any callable agent works; adjust auth + payload to fit yours.) From 438e20a4d15c824773c46d4cbba56de95fd04e91 Mon Sep 17 00:00:00 2001 From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:03:29 -0600 Subject: [PATCH 20/29] :recycle: Make doc more concise Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> --- authbridge/docs/session-budget-plugin.md | 107 +++++++---------------- 1 file changed, 30 insertions(+), 77 deletions(-) diff --git a/authbridge/docs/session-budget-plugin.md b/authbridge/docs/session-budget-plugin.md index 10976f05b..4974a4418 100644 --- a/authbridge/docs/session-budget-plugin.md +++ b/authbridge/docs/session-budget-plugin.md @@ -104,20 +104,11 @@ calibrate limits before enforcing: On breach, POST to `pause_webhook` and block the request until the webhook responds or `pause_timeout` fires. -**What the webhook is.** Any HTTP endpoint that speaks the contract below. You -build and operate it — session-budget doesn't ship one. Common shapes: - -- **A Kubernetes Service** in the cluster (e.g. an approval controller or a - small in-house service that decides based on session metadata). -- **A workflow entrypoint** — Temporal, Argo, GitHub Actions - `repository_dispatch`, Slack/PagerDuty middleware, etc. — that synchronously - blocks on an operator's response. -- **A stub for local development** — a tiny handler returning a hardcoded - `{"action":"approve"}` for smoke tests. - -Whatever it is, `pause_webhook` must be reachable from the AuthBridge pod on the -outbound path and return within `pause_timeout` (default `30s`) or the plugin -falls back to `pause_timeout_action`. +The webhook is any HTTP endpoint you build that speaks the contract +below — an in-cluster Service, a workflow entrypoint (Temporal, Argo, +Slack middleware), or a local stub. It must be reachable from the +AuthBridge pod and respond within `pause_timeout` (default `30s`), or +the plugin falls back to `pause_timeout_action`. **Contract:** @@ -156,37 +147,20 @@ or {"action": "deny", "reason": "operator rejected"} ``` -**On approval:** the request continues, and subsequent requests from the same -session skip the webhook for `pause_grace_period` (default `5m`). This prevents -per-request webhook spam once a session is approved. Concurrent breaches during -an in-flight webhook piggyback on the pending call rather than each firing -their own. - -Requests that arrive during an in-flight webhook piggyback on it and continue -optimistically. If the webhook ultimately denies, those extra requests have -already passed. +**On approval:** the request continues; subsequent requests from the +same session skip the webhook for `pause_grace_period` (default `5m`, +pod-local — each pod fires one webhook before its own grace kicks in). +Concurrent breaches during an in-flight webhook piggyback on the +pending call and continue optimistically; if that call ultimately +denies, those extra requests have already passed. **On timeout / non-200 / bad JSON / unreachable:** falls back to -`pause_timeout_action` (`deny` returns 403; `allow` continues). - -The grace window is pod-local (in-memory). In multi-pod deployments without -sticky sessions, each pod fires one webhook before its own grace kicks in. - -**Implementation tips for the webhook:** - -- **Key on `session_id`.** All fields in the request describe one session. Use - `session_id` as the correlation key if you queue, cache, or fan out to human - reviewers. -- **Respond fast, or make `pause_timeout` generous.** The caller's request - goroutine is blocked for the full webhook duration. If a human is in the - loop, either bump `pause_timeout` (minutes) or have the webhook return `deny` - immediately and approve out-of-band on a later request. -- **Idempotency isn't required** but is nice to have. Under bursty breaches - you'll typically see one call per (session, pod) before grace kicks in; - concurrent breaches on the same pod are coalesced. -- **Health matters.** An unreachable / 5xx / slow webhook falls back to - `pause_timeout_action`. If that's `deny`, an outage of your webhook turns - budget breaches into hard 403s. +`pause_timeout_action` (`deny` returns 403; `allow` continues). If your +webhook can be unhealthy and `pause_timeout_action: deny`, an outage +turns budget breaches into hard 403s. + +If a human is in the loop, either bump `pause_timeout` to minutes or +have the webhook return `deny` immediately and approve out-of-band. ## Failure Modes @@ -200,20 +174,18 @@ sticky sessions, each pod fires one webhook before its own grace kicks in. ### Cold-cache behavior -Cold-cache handling on `OnRequest` is mode-dependent: +When a request arrives with no local cache entry for the session: -- **`pause`** — synchronously hydrates from Redis before evaluating, so a - session already over-budget on Redis fires the webhook on request #1. - HITL only works if we ask before continuing. +- **`pause`** — hydrates from Redis synchronously, so an over-budget + session fires the webhook on request #1. - **`deny` / `observe`** — skip with `reason=cold_cache` and continue. - Counters populate via `OnResponseFrame` and the refresh loop. A - pre-existing over-budget session may pass **up to one request per pod** - before enforcement resumes — the same tradeoff these modes have always - had. Keeps Redis off the hot path. + Counters populate as inference responses stream back and via the + background refresh loop. A pre-existing over-budget session may pass + **up to one request per pod** before enforcement resumes. Keeps Redis + off the hot path. -Infrastructure failures never produce false denials — Redis unavailability -degrades enforcement to local-cache-only (no cross-pod consistency) rather than -blocking requests. +Redis unavailability degrades enforcement to local-cache-only rather +than blocking requests. **Token counting requires `usage` in provider responses.** Providers that omit `usage` from streaming chunks (e.g. Anthropic via LiteLLM) will show @@ -262,29 +234,10 @@ Swap `approve` for `deny` to test the reject path. Logs land in **If your namespace runs Istio ambient mesh** (label `istio.io/dataplane-mode: ambient`), the Valkey pod and any plain-HTTP pause webhook need to opt out with the pod-level label -`istio.io/dataplane-mode: none`. This matters most for `on_exceed: pause`, -which puts a synchronous Redis lookup on the request path (see -"Cold-cache behavior"); deny/observe tolerate Redis being unreachable on -the hot path but still need it for cross-pod counter sync via the refresh -loop. Here's why: - -Ambient mesh puts a per-node proxy (ztunnel) in front of every enrolled -pod. Traffic between enrolled pods is wrapped in **HBONE** — HTTP/2 -CONNECT tunnels with mTLS between ztunnels. The destination ztunnel -only accepts HBONE; anything else gets closed at L4 with -`Connection reset by peer`. - -Redis (and Valkey) speaks its own binary protocol (RESP) directly over -TCP — it can't be tunneled inside HTTP/2, so ambient's ztunnel rejects -the connection before it ever reaches Valkey. Same story for any -plain-TCP or plain-HTTP service you don't want mesh-managed. Opting -those pods out of ambient makes them normal Kubernetes pods again, so -callers reach them over plain TCP. - -Symptom if you forget: `session-budget action=skip reason=cold_cache` -on every request even though `HGETALL session-budget:` in Redis -shows the key — the plugin's Redis lookup is being closed by ztunnel -before it reaches Valkey. +`istio.io/dataplane-mode: none`. Ambient's ztunnel only accepts HBONE +(HTTP/2 CONNECT), and Redis RESP is raw TCP — the connection is closed +before reaching Valkey. Symptom: `session-budget action=skip +reason=cold_cache` on every request even though the Redis key exists. For an in-cluster stub, apply `authbridge/demos/session-budget/k8s/pause-webhook-stub.yaml` and set From 4b041c21d1cd6d63ee263a89dc4093626dffffac Mon Sep 17 00:00:00 2001 From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:01:39 -0600 Subject: [PATCH 21/29] :art: Address review comments Assisted-By: Claude (Anthropic AI) Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> --- .../authlib/plugins/sessionbudget/e2e_test.go | 102 +++++++++--- .../authlib/plugins/sessionbudget/plugin.go | 99 +++++++++--- .../plugins/sessionbudget/plugin_test.go | 151 ++++++++++++------ authbridge/demos/session-budget/README.md | 23 ++- .../k8s/pause-webhook-stub.yaml | 11 ++ authbridge/docs/session-budget-plugin.md | 6 +- 6 files changed, 297 insertions(+), 95 deletions(-) diff --git a/authbridge/authlib/plugins/sessionbudget/e2e_test.go b/authbridge/authlib/plugins/sessionbudget/e2e_test.go index 1dd102e12..daf2cf342 100644 --- a/authbridge/authlib/plugins/sessionbudget/e2e_test.go +++ b/authbridge/authlib/plugins/sessionbudget/e2e_test.go @@ -162,8 +162,21 @@ func TestE2E_MultiSession(t *testing.T) { // TestE2E_LocalCacheEnforcesDuringOutage confirms that a populated // cache enforces even when the backing store is unreachable. func TestE2E_LocalCacheEnforcesDuringOutage(t *testing.T) { - p := newE2EPlugin(t, 100, newMemStore()) + // Build without starting refreshLoop so we can swap store safely. + p := New() + cfg, _ := json.Marshal(config{ + RedisURL: "mem://test", + MaxTokens: 100, + OnExceed: "deny", + RefreshInterval: "30ms", + RedisUnavailable: "fail_open", + }) + if err := p.Configure(cfg); err != nil { + t.Fatalf("Configure: %v", err) + } p.store = &failingStore{} + go p.refreshLoop(30 * time.Millisecond) + t.Cleanup(func() { close(p.stopCh); <-p.stopped }) p.mu.Lock() p.cache["s"] = &counters{tokens: 110, calls: 5, startedAt: time.Now()} @@ -179,7 +192,19 @@ func TestE2E_LocalCacheEnforcesDuringOutage(t *testing.T) { func TestE2E_RefreshRecovery(t *testing.T) { inner := newMemStore() cs := &controllableStore{inner: inner} - p := newE2EPlugin(t, 200, newMemStore()) + // Build without a background refreshLoop so refreshCache is invoked + // deterministically and store swaps are race-free. + p := New() + cfg, _ := json.Marshal(config{ + RedisURL: "mem://test", + MaxTokens: 200, + OnExceed: "deny", + RefreshInterval: "30ms", + RedisUnavailable: "fail_open", + }) + if err := p.Configure(cfg); err != nil { + t.Fatalf("Configure: %v", err) + } p.store = cs ctx := context.Background() @@ -194,18 +219,20 @@ func TestE2E_RefreshRecovery(t *testing.T) { cs.setFailing(true) p.refreshCache() p.mu.RLock() - if p.cache["s"].tokens != 50 { - t.Fatalf("during outage: tokens = %d, want 50", p.cache["s"].tokens) - } + gotDuring := p.cache["s"].tokens p.mu.RUnlock() + if gotDuring != 50 { + t.Fatalf("during outage: tokens = %d, want 50", gotDuring) + } cs.setFailing(false) p.refreshCache() p.mu.RLock() - if p.cache["s"].tokens != 180 { - t.Errorf("after recovery: tokens = %d, want 180", p.cache["s"].tokens) - } + gotAfter := p.cache["s"].tokens p.mu.RUnlock() + if gotAfter != 180 { + t.Errorf("after recovery: tokens = %d, want 180", gotAfter) + } } // TestE2E_PodRestart verifies that a fresh plugin with an empty cache @@ -247,8 +274,25 @@ func TestE2E_HydrateSingleflight(t *testing.T) { _, _ = w.Write([]byte(`{"action":"deny"}`)) })) defer webhook.Close() - p := newE2EPluginPause(t, 200, newMemStore(), webhook.URL) + // Build without starting refreshLoop so background HashGet calls don't + // pollute the singleflight counter we're asserting on. + p := New() + cfg, _ := json.Marshal(config{ + RedisURL: "mem://test", + MaxTokens: 200, + OnExceed: "pause", + PauseWebhook: webhook.URL, + PauseTimeout: "2s", + PauseTimeoutAction: "deny", + PauseGracePeriod: "0s", + RefreshInterval: "30ms", + RedisUnavailable: "fail_open", + }) + if err := p.Configure(cfg); err != nil { + t.Fatalf("Configure: %v", err) + } p.store = cs + p.httpClient = &http.Client{Timeout: 0} ctx := context.Background() inner.HashIncr(ctx, "session-budget:s", "tokens", 210) @@ -290,25 +334,33 @@ type controllableStore struct { hashGets int } -func (c *controllableStore) setFailing(v bool) { c.mu.Lock(); c.failing = v; c.mu.Unlock() } -func (c *controllableStore) isFailing() bool { c.mu.Lock(); defer c.mu.Unlock(); return c.failing } -func (c *controllableStore) hashGetCalls() int { c.mu.Lock(); defer c.mu.Unlock(); return c.hashGets } -func (c *controllableStore) err() error { return context.DeadlineExceeded } +func (c *controllableStore) setFailing(v bool) { c.mu.Lock(); c.failing = v; c.mu.Unlock() } +func (c *controllableStore) isFailing() bool { c.mu.Lock(); defer c.mu.Unlock(); return c.failing } +func (c *controllableStore) hashGetCalls() int { c.mu.Lock(); defer c.mu.Unlock(); return c.hashGets } +func (c *controllableStore) err() error { return context.DeadlineExceeded } func (c *controllableStore) Get(ctx context.Context, key string) (string, error) { - if c.isFailing() { return "", c.err() } + if c.isFailing() { + return "", c.err() + } return c.inner.Get(ctx, key) } func (c *controllableStore) Set(ctx context.Context, key, value string, ttl time.Duration) error { - if c.isFailing() { return c.err() } + if c.isFailing() { + return c.err() + } return c.inner.Set(ctx, key, value, ttl) } func (c *controllableStore) Incr(ctx context.Context, key string, delta int64) (int64, error) { - if c.isFailing() { return 0, c.err() } + if c.isFailing() { + return 0, c.err() + } return c.inner.Incr(ctx, key, delta) } func (c *controllableStore) HashIncr(ctx context.Context, key, field string, delta int64) (int64, error) { - if c.isFailing() { return 0, c.err() } + if c.isFailing() { + return 0, c.err() + } return c.inner.HashIncr(ctx, key, field, delta) } func (c *controllableStore) HashGet(ctx context.Context, key string) (map[string]string, error) { @@ -317,16 +369,24 @@ func (c *controllableStore) HashGet(ctx context.Context, key string) (map[string delay := c.hashGetDelay failing := c.failing c.mu.Unlock() - if delay > 0 { time.Sleep(delay) } - if failing { return nil, c.err() } + if delay > 0 { + time.Sleep(delay) + } + if failing { + return nil, c.err() + } return c.inner.HashGet(ctx, key) } func (c *controllableStore) HashSetNX(ctx context.Context, key, field, value string) (bool, error) { - if c.isFailing() { return false, c.err() } + if c.isFailing() { + return false, c.err() + } return c.inner.HashSetNX(ctx, key, field, value) } func (c *controllableStore) Expire(ctx context.Context, key string, ttl time.Duration) error { - if c.isFailing() { return c.err() } + if c.isFailing() { + return c.err() + } return c.inner.Expire(ctx, key, ttl) } func (c *controllableStore) Close() error { return nil } diff --git a/authbridge/authlib/plugins/sessionbudget/plugin.go b/authbridge/authlib/plugins/sessionbudget/plugin.go index 883ebeb8f..75071394a 100644 --- a/authbridge/authlib/plugins/sessionbudget/plugin.go +++ b/authbridge/authlib/plugins/sessionbudget/plugin.go @@ -38,11 +38,20 @@ type config struct { } type counters struct { - tokens int64 - calls int64 - startedAt time.Time - lastApprovedAt time.Time - pendingApproval bool + tokens int64 + calls int64 + startedAt time.Time + lastApprovedAt time.Time + // pendingApproval is non-nil while a webhook call for this session is in + // flight. Concurrent breaches wait on the channel; the leader closes it + // after publishing the outcome to pendingResult, then clears both fields. + pendingApproval chan struct{} + pendingResult bool + // pendingWrites counts in-flight accumulate goroutines whose Redis writes + // haven't landed yet. refreshCache leaves the local entry alone (rather + // than deleting a Redis-missing session) while this is > 0, so a race + // between accumulate and a refresh tick can't erase local counters. + pendingWrites int } // SessionBudget is the plugin state. Redis provides cross-pod durability; @@ -158,9 +167,16 @@ func (p *SessionBudget) Init(_ context.Context) error { } // In-flight accumulate goroutines get ErrClosed after store.Close — bounded by their 2s ctx. -func (p *SessionBudget) Shutdown(_ context.Context) error { +func (p *SessionBudget) Shutdown(ctx context.Context) error { close(p.stopCh) - <-p.stopped + select { + case <-p.stopped: + case <-ctx.Done(): + if p.store != nil { + _ = p.store.Close() + } + return ctx.Err() + } if p.store != nil { return p.store.Close() } @@ -221,19 +237,36 @@ func (p *SessionBudget) OnRequest(ctx context.Context, pctx *pipeline.Context) p return pipeline.Action{Type: pipeline.Continue} case "pause": - // Grace window: skip webhook if recently approved or another request is already waiting. + // Grace window: skip webhook if recently approved. if p.gracePeriod > 0 && !c.lastApprovedAt.IsZero() && time.Since(c.lastApprovedAt) < p.gracePeriod { p.mu.Unlock() pctx.Allow("pause_grace_window") return pipeline.Action{Type: pipeline.Continue} } - if c.pendingApproval { - // Another goroutine is already calling the webhook — piggyback on grace. + if c.pendingApproval != nil { + // Another goroutine is already calling the webhook — wait for + // its outcome so followers honor a deny instead of racing past. + waitCh := c.pendingApproval p.mu.Unlock() - pctx.Allow("pause_pending_approval") - return pipeline.Action{Type: pipeline.Continue} + select { + case <-waitCh: + case <-ctx.Done(): + pctx.Record(pipeline.Invocation{Action: pipeline.ActionDeny, Reason: "pause_wait_canceled"}) + return pipeline.DenyWithDetails("budget.exceeded", reason+" (client canceled during pause)", p.buildDetails(&snap)) + } + p.mu.RLock() + cc, ok := p.cache[sessionID] + approved := ok && cc.pendingResult + p.mu.RUnlock() + if approved { + pctx.Allow("pause_follower_approved") + return pipeline.Action{Type: pipeline.Continue} + } + pctx.Record(pipeline.Invocation{Action: pipeline.ActionDeny, Reason: "pause_follower_denied"}) + return pipeline.DenyWithDetails("budget.exceeded", reason+" (approval denied)", p.buildDetails(&snap)) } - c.pendingApproval = true + waitCh := make(chan struct{}) + c.pendingApproval = waitCh p.mu.Unlock() p.log.Info("budget exceeded, requesting approval", "session", sessionID, @@ -241,12 +274,14 @@ func (p *SessionBudget) OnRequest(ctx context.Context, pctx *pipeline.Context) p approved := p.callPauseWebhook(ctx, sessionID, reason, &snap) p.mu.Lock() if cc, ok := p.cache[sessionID]; ok { - cc.pendingApproval = false + cc.pendingResult = approved + cc.pendingApproval = nil if approved { cc.lastApprovedAt = time.Now() } } p.mu.Unlock() + close(waitCh) if approved { pctx.Allow("pause_approved") return pipeline.Action{Type: pipeline.Continue} @@ -292,8 +327,6 @@ func (p *SessionBudget) OnResponseFrame(_ context.Context, pctx *pipeline.Contex tokens := int64(inf.TotalTokens) - go p.accumulate(sessionID, tokens) - p.mu.Lock() c, ok := p.cache[sessionID] if !ok { @@ -302,8 +335,11 @@ func (p *SessionBudget) OnResponseFrame(_ context.Context, pctx *pipeline.Contex } c.tokens += tokens c.calls++ + c.pendingWrites++ p.mu.Unlock() + go p.accumulate(sessionID, tokens) + return pipeline.Action{Type: pipeline.Continue} } @@ -400,6 +436,14 @@ func (p *SessionBudget) evaluate(c *counters) string { // accumulate writes counters to Redis. On failure, writes are dropped (fail-open). func (p *SessionBudget) accumulate(sessionID string, tokens int64) { + defer func() { + p.mu.Lock() + if cc, ok := p.cache[sessionID]; ok && cc.pendingWrites > 0 { + cc.pendingWrites-- + } + p.mu.Unlock() + }() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() @@ -488,7 +532,12 @@ func (p *SessionBudget) refreshCache() { if len(fields) == 0 { p.mu.Lock() - delete(p.cache, sessionID) + // Only delete when there are no in-flight accumulate goroutines — + // otherwise a slow Redis write racing with a refresh tick would + // wipe the local entry before its counters land in Redis. + if existing, ok := p.cache[sessionID]; !ok || existing.pendingWrites == 0 { + delete(p.cache, sessionID) + } p.mu.Unlock() continue } @@ -502,7 +551,9 @@ func (p *SessionBudget) refreshCache() { p.mu.Lock() var lastApprovedAt time.Time - var pendingApproval bool + var pendingApproval chan struct{} + var pendingResult bool + var pendingWrites int if existing, ok := p.cache[sessionID]; ok { // Take the max of local and Redis to avoid regressing counters when // in-flight accumulate goroutines haven't committed to Redis yet. @@ -518,8 +569,18 @@ func (p *SessionBudget) refreshCache() { lastApprovedAt = existing.lastApprovedAt // Preserve mid-webhook: dropping would let a concurrent breach fire a duplicate. pendingApproval = existing.pendingApproval + pendingResult = existing.pendingResult + pendingWrites = existing.pendingWrites + } + p.cache[sessionID] = &counters{ + tokens: tokens, + calls: calls, + startedAt: startedAt, + lastApprovedAt: lastApprovedAt, + pendingApproval: pendingApproval, + pendingResult: pendingResult, + pendingWrites: pendingWrites, } - p.cache[sessionID] = &counters{tokens: tokens, calls: calls, startedAt: startedAt, lastApprovedAt: lastApprovedAt, pendingApproval: pendingApproval} p.mu.Unlock() } } diff --git a/authbridge/authlib/plugins/sessionbudget/plugin_test.go b/authbridge/authlib/plugins/sessionbudget/plugin_test.go index a2dfb9cb1..c2dec9adc 100644 --- a/authbridge/authlib/plugins/sessionbudget/plugin_test.go +++ b/authbridge/authlib/plugins/sessionbudget/plugin_test.go @@ -115,13 +115,25 @@ var _ storage.Store = (*memStore)(nil) // failingStore always returns errors (simulates total store unavailability). type failingStore struct{} -func (failingStore) Get(context.Context, string) (string, error) { return "", context.DeadlineExceeded } -func (failingStore) Set(context.Context, string, string, time.Duration) error { return context.DeadlineExceeded } -func (failingStore) Incr(context.Context, string, int64) (int64, error) { return 0, context.DeadlineExceeded } -func (failingStore) HashIncr(context.Context, string, string, int64) (int64, error) { return 0, context.DeadlineExceeded } -func (failingStore) HashGet(context.Context, string) (map[string]string, error) { return nil, context.DeadlineExceeded } -func (failingStore) HashSetNX(context.Context, string, string, string) (bool, error) { return false, context.DeadlineExceeded } -func (failingStore) Expire(context.Context, string, time.Duration) error { return context.DeadlineExceeded } +func (failingStore) Get(context.Context, string) (string, error) { return "", context.DeadlineExceeded } +func (failingStore) Set(context.Context, string, string, time.Duration) error { + return context.DeadlineExceeded +} +func (failingStore) Incr(context.Context, string, int64) (int64, error) { + return 0, context.DeadlineExceeded +} +func (failingStore) HashIncr(context.Context, string, string, int64) (int64, error) { + return 0, context.DeadlineExceeded +} +func (failingStore) HashGet(context.Context, string) (map[string]string, error) { + return nil, context.DeadlineExceeded +} +func (failingStore) HashSetNX(context.Context, string, string, string) (bool, error) { + return false, context.DeadlineExceeded +} +func (failingStore) Expire(context.Context, string, time.Duration) error { + return context.DeadlineExceeded +} func (failingStore) Close() error { return nil } func init() { @@ -171,7 +183,6 @@ func TestOnRequest_UnderLimit(t *testing.T) { } } - func TestOnResponseFrame_Accumulates(t *testing.T) { p := newTestPlugin(1000, 0, 0) pctx := makePctx("sess-1", 42) @@ -489,9 +500,9 @@ func TestOnRequest_PauseWebhookUnreachable(t *testing.T) { } func TestRefreshCache_PreservesLastApprovedAt(t *testing.T) { - webhookCalls := 0 + var webhookCalls atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - webhookCalls++ + webhookCalls.Add(1) w.WriteHeader(http.StatusOK) w.Write([]byte(`{"action":"approve"}`)) })) @@ -524,8 +535,8 @@ func TestRefreshCache_PreservesLastApprovedAt(t *testing.T) { if action.Type != pipeline.Continue { t.Fatalf("expected Continue after approval, got %v", action.Type) } - if webhookCalls != 1 { - t.Fatalf("expected 1 webhook call, got %d", webhookCalls) + if c := webhookCalls.Load(); c != 1 { + t.Fatalf("expected 1 webhook call, got %d", c) } // Simulate Redis having authoritative counters. @@ -542,15 +553,15 @@ func TestRefreshCache_PreservesLastApprovedAt(t *testing.T) { if action.Type != pipeline.Continue { t.Fatalf("expected Continue within grace after refresh, got %v", action.Type) } - if webhookCalls != 1 { - t.Fatalf("expected still 1 webhook call after refresh, got %d", webhookCalls) + if c := webhookCalls.Load(); c != 1 { + t.Fatalf("expected still 1 webhook call after refresh, got %d", c) } } func TestOnRequest_PauseGraceWindow(t *testing.T) { - webhookCalls := 0 + var webhookCalls atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - webhookCalls++ + webhookCalls.Add(1) w.WriteHeader(http.StatusOK) w.Write([]byte(`{"action":"approve"}`)) })) @@ -582,8 +593,8 @@ func TestOnRequest_PauseGraceWindow(t *testing.T) { if action.Type != pipeline.Continue { t.Fatalf("first request: expected Continue, got %v", action.Type) } - if webhookCalls != 1 { - t.Fatalf("expected 1 webhook call, got %d", webhookCalls) + if c := webhookCalls.Load(); c != 1 { + t.Fatalf("expected 1 webhook call, got %d", c) } // Second request within grace window skips the webhook. @@ -591,15 +602,15 @@ func TestOnRequest_PauseGraceWindow(t *testing.T) { if action.Type != pipeline.Continue { t.Fatalf("second request (grace): expected Continue, got %v", action.Type) } - if webhookCalls != 1 { - t.Fatalf("expected still 1 webhook call after grace, got %d", webhookCalls) + if c := webhookCalls.Load(); c != 1 { + t.Fatalf("expected still 1 webhook call after grace, got %d", c) } } func TestOnRequest_PauseGraceExpired(t *testing.T) { - webhookCalls := 0 + var webhookCalls atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - webhookCalls++ + webhookCalls.Add(1) w.WriteHeader(http.StatusOK) w.Write([]byte(`{"action":"approve"}`)) })) @@ -632,19 +643,25 @@ func TestOnRequest_PauseGraceExpired(t *testing.T) { } p.mu.Unlock() - // Request after grace expired fires webhook. - p.OnRequest(context.Background(), makePctx("sess", 0)) - if webhookCalls != 1 { - t.Fatalf("expected 1 webhook call after grace expired, got %d", webhookCalls) + // Request after grace expired fires webhook and continues on approve. + action := p.OnRequest(context.Background(), makePctx("sess", 0)) + if action.Type != pipeline.Continue { + t.Fatalf("expected Continue after grace expired + approve, got %v", action.Type) + } + if c := webhookCalls.Load(); c != 1 { + t.Fatalf("expected 1 webhook call after grace expired, got %d", c) } } +// TestOnRequest_PausePendingApprovalSentinel verifies that concurrent +// breaches share one webhook call AND that the follower waits for the +// leader's outcome (rather than racing past optimistically). func TestOnRequest_PausePendingApprovalSentinel(t *testing.T) { - var webhookCalls int32 + var webhookCalls atomic.Int32 started := make(chan struct{}) proceed := make(chan struct{}) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - atomic.AddInt32(&webhookCalls, 1) + webhookCalls.Add(1) close(started) <-proceed w.WriteHeader(http.StatusOK) @@ -657,26 +674,70 @@ func TestOnRequest_PausePendingApprovalSentinel(t *testing.T) { p.cache["sess"] = &counters{tokens: 0, calls: 3, startedAt: time.Now()} p.mu.Unlock() - // First goroutine fires the webhook and blocks. + // Two concurrent requests: leader fires the webhook, follower must wait. var wg sync.WaitGroup - wg.Add(1) - go func() { - defer wg.Done() - p.OnRequest(context.Background(), makePctx("sess", 0)) - }() - <-started // webhook is in-flight - - // Second concurrent request should piggyback (pendingApproval=true). - action := p.OnRequest(context.Background(), makePctx("sess", 0)) - if action.Type != pipeline.Continue { - t.Fatalf("concurrent request: expected Continue (piggyback), got %v", action.Type) + results := make([]pipeline.ActionType, 2) + for i := 0; i < 2; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + a := p.OnRequest(context.Background(), makePctx("sess", 0)) + results[idx] = a.Type + }(i) + } + <-started // leader's webhook is in-flight; follower is now waiting on the channel + + // Unblock the webhook — both goroutines should complete after leader returns. + close(proceed) + wg.Wait() + + if c := webhookCalls.Load(); c != 1 { + t.Errorf("webhook called %d times, want exactly 1 (sentinel prevents thundering herd)", c) } + for i, got := range results { + if got != pipeline.Continue { + t.Errorf("request %d: got %v, want Continue (webhook approved)", i, got) + } + } +} - close(proceed) // unblock the webhook +// TestOnRequest_PauseFollowerHonorsDeny verifies that a follower waiting +// on the leader's webhook call gets Reject when the leader is denied, +// instead of racing past optimistically. +func TestOnRequest_PauseFollowerHonorsDeny(t *testing.T) { + started := make(chan struct{}) + proceed := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + close(started) + <-proceed + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"action":"deny"}`)) + })) + defer srv.Close() + + p := newPausePlugin(t, 3, srv.URL, "deny") + p.mu.Lock() + p.cache["sess"] = &counters{tokens: 0, calls: 3, startedAt: time.Now()} + p.mu.Unlock() + + var wg sync.WaitGroup + results := make([]pipeline.ActionType, 2) + for i := 0; i < 2; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + a := p.OnRequest(context.Background(), makePctx("sess", 0)) + results[idx] = a.Type + }(i) + } + <-started + close(proceed) wg.Wait() - if c := atomic.LoadInt32(&webhookCalls); c != 1 { - t.Errorf("webhook called %d times, want exactly 1 (sentinel prevents thundering herd)", c) + for i, got := range results { + if got != pipeline.Reject { + t.Errorf("request %d: got %v, want Reject (webhook denied)", i, got) + } } } @@ -720,8 +781,8 @@ func TestEvaluate_MultipleLimits(t *testing.T) { p := newTestPlugin(100, 10, 60) tests := []struct { - name string - c *counters + name string + c *counters wantDeny bool }{ {"all under", &counters{tokens: 50, calls: 5, startedAt: time.Now()}, false}, diff --git a/authbridge/demos/session-budget/README.md b/authbridge/demos/session-budget/README.md index b90beb9d3..1a58355a7 100644 --- a/authbridge/demos/session-budget/README.md +++ b/authbridge/demos/session-budget/README.md @@ -20,7 +20,7 @@ return `{"action":"deny"}` and re-apply. Follow the webhook stub: ```bash -kubectl logs -n team1 deploy/pause-webhook-stub -f +kubectl logs -n "$NS" deploy/pause-webhook-stub -f ``` ## Prerequisites @@ -73,26 +73,35 @@ above. Substitute your own agent, session id, and A2A payload. ```bash NS=team1 -AGENT_POD=$(kubectl -n $NS get pod -l app.kubernetes.io/name= \ +AGENT_POD=$(kubectl -n "$NS" get pod -l app.kubernetes.io/name= \ -o jsonpath='{.items[0].metadata.name}') SESSION=demo-$RANDOM +# Substitute your Redis/Valkey pod + CLI. E.g. REDIS_POD=valkey and +# REDIS_CLI=valkey-cli, or REDIS_POD=redis-0 and REDIS_CLI=redis-cli. +REDIS_POD=valkey +REDIS_CLI=valkey-cli + # 1. Seed Redis so this session is already over budget. -kubectl -n $NS exec valkey -- valkey-cli HSET \ - session-budget:$SESSION calls 99 started_at $(date +%s) +kubectl -n "$NS" exec "$REDIS_POD" -- "$REDIS_CLI" HSET \ + "session-budget:$SESSION" calls 99 started_at "$(date +%s)" # 2. Fire one A2A request with contextId = seeded session. # (Any callable agent works; adjust auth + payload to fit yours.) -kubectl -n $NS port-forward pod/$AGENT_POD 8000:8000 & +# $TOKEN is a Keycloak-issued bearer for the agent's inbound audience — +# obtain via the same setup script you use for the rest of your demos +# (see e.g. authbridge/demos/weather-agent). If your agent's inbound +# plugin chain has no jwt-validation, omit the Authorization header. +kubectl -n "$NS" port-forward pod/"$AGENT_POD" 8000:8000 & curl -sS -X POST http://localhost:8000/ \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":"1","method":"message/send","params":{ "message":{"messageId":"m1","role":"user", "parts":[{"kind":"text","text":"hi"}], - "contextId":"'$SESSION'"}}}' + "contextId":"'"$SESSION"'"}}}' # 3. Confirm the webhook was called with the right session_id. -kubectl -n $NS logs deploy/pause-webhook-stub | grep $SESSION +kubectl -n "$NS" logs deploy/pause-webhook-stub | grep "$SESSION" ``` Expected: the request returns 200 (stub approves), and the webhook log diff --git a/authbridge/demos/session-budget/k8s/pause-webhook-stub.yaml b/authbridge/demos/session-budget/k8s/pause-webhook-stub.yaml index d67786f83..b65fb30b4 100644 --- a/authbridge/demos/session-budget/k8s/pause-webhook-stub.yaml +++ b/authbridge/demos/session-budget/k8s/pause-webhook-stub.yaml @@ -34,9 +34,20 @@ spec: annotations: ambient.istio.io/redirection: disabled spec: + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + seccompProfile: + type: RuntimeDefault containers: - name: stub image: python:3.12-alpine + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] command: ["python", "-c"] args: - | diff --git a/authbridge/docs/session-budget-plugin.md b/authbridge/docs/session-budget-plugin.md index 4974a4418..7ceeaeb7c 100644 --- a/authbridge/docs/session-budget-plugin.md +++ b/authbridge/docs/session-budget-plugin.md @@ -159,8 +159,8 @@ denies, those extra requests have already passed. webhook can be unhealthy and `pause_timeout_action: deny`, an outage turns budget breaches into hard 403s. -If a human is in the loop, either bump `pause_timeout` to minutes or -have the webhook return `deny` immediately and approve out-of-band. +If a human is in the loop, bump `pause_timeout` to minutes so the +request can wait for a real approval decision. ## Failure Modes @@ -195,7 +195,7 @@ OpenAI, and Azure OpenAI include usage in streaming responses and work fully. ## Redis Keys -``` +```text session-budget: (Hash, TTL = session_ttl_seconds) tokens cumulative token count calls inference call count From 1a3bbcfec3981627468294982ee974cd8eaf3be6 Mon Sep 17 00:00:00 2001 From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:42:14 -0600 Subject: [PATCH 22/29] :art: Address review comments Assisted-By: Claude (Anthropic AI) Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> --- .../authlib/plugins/sessionbudget/plugin.go | 51 ++-- .../plugins/sessionbudget/plugin_test.go | 246 +++++++++++++++++- 2 files changed, 273 insertions(+), 24 deletions(-) diff --git a/authbridge/authlib/plugins/sessionbudget/plugin.go b/authbridge/authlib/plugins/sessionbudget/plugin.go index 75071394a..49b4d58e7 100644 --- a/authbridge/authlib/plugins/sessionbudget/plugin.go +++ b/authbridge/authlib/plugins/sessionbudget/plugin.go @@ -37,16 +37,28 @@ type config struct { RedisUnavailable string `json:"redis_unavailable" description:"Behavior when Redis is unreachable. Only fail_open is supported; fail_closed is reserved." default:"fail_open"` } +// approvalFlight carries the outcome of one webhook call. The leader writes +// approved before closing done; followers read it only after receiving from +// done, so the happens-before edge is safe without further synchronization. +// Attaching the result to the flight (instead of the cache entry) makes each +// waiter observe the outcome of the flight it actually waited on — a new +// leader that starts a second webhook after this one closes cannot clobber +// this flight's approved, and a refreshCache that deletes the cache entry +// mid-flight cannot make followers read a stale zero value. +type approvalFlight struct { + done chan struct{} + approved bool +} + type counters struct { tokens int64 calls int64 startedAt time.Time lastApprovedAt time.Time // pendingApproval is non-nil while a webhook call for this session is in - // flight. Concurrent breaches wait on the channel; the leader closes it - // after publishing the outcome to pendingResult, then clears both fields. - pendingApproval chan struct{} - pendingResult bool + // flight. Concurrent breaches wait on flight.done; the leader publishes + // flight.approved before closing done, then clears this field. + pendingApproval *approvalFlight // pendingWrites counts in-flight accumulate goroutines whose Redis writes // haven't landed yet. refreshCache leaves the local entry alone (rather // than deleting a Redis-missing session) while this is > 0, so a race @@ -172,9 +184,11 @@ func (p *SessionBudget) Shutdown(ctx context.Context) error { select { case <-p.stopped: case <-ctx.Done(): - if p.store != nil { - _ = p.store.Close() - } + // refreshLoop is still running and calls p.store.HashGet; closing the + // store here would race that call. Leave the store open and let the + // process exit reclaim it — refreshLoop terminates when p.stopCh + // closes, and the shutdown-deadline is already exceeded. + p.log.Warn("shutdown timed out waiting for refresh loop; leaving store open") return ctx.Err() } if p.store != nil { @@ -246,42 +260,38 @@ func (p *SessionBudget) OnRequest(ctx context.Context, pctx *pipeline.Context) p if c.pendingApproval != nil { // Another goroutine is already calling the webhook — wait for // its outcome so followers honor a deny instead of racing past. - waitCh := c.pendingApproval + flight := c.pendingApproval p.mu.Unlock() select { - case <-waitCh: + case <-flight.done: case <-ctx.Done(): pctx.Record(pipeline.Invocation{Action: pipeline.ActionDeny, Reason: "pause_wait_canceled"}) return pipeline.DenyWithDetails("budget.exceeded", reason+" (client canceled during pause)", p.buildDetails(&snap)) } - p.mu.RLock() - cc, ok := p.cache[sessionID] - approved := ok && cc.pendingResult - p.mu.RUnlock() - if approved { + if flight.approved { pctx.Allow("pause_follower_approved") return pipeline.Action{Type: pipeline.Continue} } pctx.Record(pipeline.Invocation{Action: pipeline.ActionDeny, Reason: "pause_follower_denied"}) return pipeline.DenyWithDetails("budget.exceeded", reason+" (approval denied)", p.buildDetails(&snap)) } - waitCh := make(chan struct{}) - c.pendingApproval = waitCh + flight := &approvalFlight{done: make(chan struct{})} + c.pendingApproval = flight p.mu.Unlock() p.log.Info("budget exceeded, requesting approval", "session", sessionID, "reason", reason) approved := p.callPauseWebhook(ctx, sessionID, reason, &snap) + flight.approved = approved p.mu.Lock() if cc, ok := p.cache[sessionID]; ok { - cc.pendingResult = approved cc.pendingApproval = nil if approved { cc.lastApprovedAt = time.Now() } } p.mu.Unlock() - close(waitCh) + close(flight.done) if approved { pctx.Allow("pause_approved") return pipeline.Action{Type: pipeline.Continue} @@ -551,8 +561,7 @@ func (p *SessionBudget) refreshCache() { p.mu.Lock() var lastApprovedAt time.Time - var pendingApproval chan struct{} - var pendingResult bool + var pendingApproval *approvalFlight var pendingWrites int if existing, ok := p.cache[sessionID]; ok { // Take the max of local and Redis to avoid regressing counters when @@ -569,7 +578,6 @@ func (p *SessionBudget) refreshCache() { lastApprovedAt = existing.lastApprovedAt // Preserve mid-webhook: dropping would let a concurrent breach fire a duplicate. pendingApproval = existing.pendingApproval - pendingResult = existing.pendingResult pendingWrites = existing.pendingWrites } p.cache[sessionID] = &counters{ @@ -578,7 +586,6 @@ func (p *SessionBudget) refreshCache() { startedAt: startedAt, lastApprovedAt: lastApprovedAt, pendingApproval: pendingApproval, - pendingResult: pendingResult, pendingWrites: pendingWrites, } p.mu.Unlock() diff --git a/authbridge/authlib/plugins/sessionbudget/plugin_test.go b/authbridge/authlib/plugins/sessionbudget/plugin_test.go index c2dec9adc..7ef154b32 100644 --- a/authbridge/authlib/plugins/sessionbudget/plugin_test.go +++ b/authbridge/authlib/plugins/sessionbudget/plugin_test.go @@ -659,10 +659,14 @@ func TestOnRequest_PauseGraceExpired(t *testing.T) { func TestOnRequest_PausePendingApprovalSentinel(t *testing.T) { var webhookCalls atomic.Int32 started := make(chan struct{}) + // Guard the close so a regression that lets a second webhook fire produces + // a readable test failure ("webhook called 2 times") instead of a + // close-of-closed-channel panic that tears down the whole binary. + signalStart := sync.OnceFunc(func() { close(started) }) proceed := make(chan struct{}) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { webhookCalls.Add(1) - close(started) + signalStart() <-proceed w.WriteHeader(http.StatusOK) w.Write([]byte(`{"action":"approve"}`)) @@ -706,9 +710,11 @@ func TestOnRequest_PausePendingApprovalSentinel(t *testing.T) { // instead of racing past optimistically. func TestOnRequest_PauseFollowerHonorsDeny(t *testing.T) { started := make(chan struct{}) + // See TestOnRequest_PausePendingApprovalSentinel for rationale. + signalStart := sync.OnceFunc(func() { close(started) }) proceed := make(chan struct{}) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - close(started) + signalStart() <-proceed w.WriteHeader(http.StatusOK) w.Write([]byte(`{"action":"deny"}`)) @@ -803,3 +809,239 @@ func TestEvaluate_MultipleLimits(t *testing.T) { }) } } + +// TestOnRequest_PauseSecondFlightDoesNotClobberFirst is the regression test +// for the CodeRabbit finding that a new leader could overwrite the previous +// flight's outcome. The old code stored the approval result on the cache +// entry (cc.pendingResult); if a follower was descheduled after receiving +// on the leader's done-channel but before it read pendingResult, a fresh +// breach starting a new flight could rewrite the field and flip the +// follower's decision. Per-flight approval attaches the outcome to the +// flight itself, so this test drives that exact interleave and asserts the +// follower reads the FIRST flight's approve, even though the second flight +// denies. Distinct from PausePendingApprovalSentinel (one flight, both +// approve) and PauseFollowerHonorsDeny (one flight, both deny). +func TestOnRequest_PauseSecondFlightDoesNotClobberFirst(t *testing.T) { + // Two sequential webhook calls with different outcomes. + var callIdx atomic.Int32 + release1 := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + n := callIdx.Add(1) + if n == 1 { + <-release1 // leader of flight 1 blocks until we say so + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"action":"approve"}`)) + return + } + // Flight 2 denies immediately. + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"action":"deny"}`)) + })) + defer srv.Close() + + p := newPausePlugin(t, 3, srv.URL, "deny") + // grace_period defaults to 5m in newPausePlugin config template? Actually + // newPausePlugin doesn't set pause_grace_period, so Configure defaults it + // to 5m. That would suppress flight 2 entirely. Disable grace explicitly + // by mutating the parsed config after Configure. + p.gracePeriod = 0 + + p.mu.Lock() + p.cache["sess"] = &counters{tokens: 0, calls: 3, startedAt: time.Now()} + p.mu.Unlock() + + // Kick off flight 1: leader + one follower, both should end up approved. + var wg1 sync.WaitGroup + f1Results := make([]pipeline.ActionType, 2) + // Leader first, so we know the follower joins after pendingApproval is set. + wg1.Add(1) + go func() { + defer wg1.Done() + a := p.OnRequest(context.Background(), makePctx("sess", 0)) + f1Results[0] = a.Type + }() + // Wait until pendingApproval is populated so the second goroutine is a follower. + deadline := time.Now().Add(2 * time.Second) + for { + p.mu.RLock() + c := p.cache["sess"] + hasFlight := c != nil && c.pendingApproval != nil + p.mu.RUnlock() + if hasFlight { + break + } + if time.Now().After(deadline) { + t.Fatal("flight 1 never populated pendingApproval") + } + time.Sleep(2 * time.Millisecond) + } + // Slow follower: capture the flight now, then pretend to be descheduled by + // waiting on the done channel through an indirect path. We simulate that + // by having a real follower goroutine but stalling its post-wait cache + // read via the plugin's own code path — the whole point is that the + // follower must not read a globally-mutable pendingResult. + wg1.Add(1) + go func() { + defer wg1.Done() + a := p.OnRequest(context.Background(), makePctx("sess", 0)) + f1Results[1] = a.Type + }() + + // Let flight 1 complete. + close(release1) + wg1.Wait() + + for i, got := range f1Results { + if got != pipeline.Continue { + t.Errorf("flight 1 request %d: got %v, want Continue (webhook approved)", i, got) + } + } + + // Simulate lastApprovedAt in the past so flight 2 can actually fire. + p.mu.Lock() + if cc, ok := p.cache["sess"]; ok { + cc.lastApprovedAt = time.Time{} + // Push calls back above the limit so evaluate() returns non-empty again. + cc.calls = 3 + } + p.mu.Unlock() + + // Flight 2 (denies). If old code's shared pendingResult were still in play, + // this deny would clobber flight 1's approve. With per-flight state, flight + // 1's return values are already frozen — this test just proves flight 2 + // stands on its own and returns Reject with its own outcome. + a2 := p.OnRequest(context.Background(), makePctx("sess", 0)) + if a2.Type != pipeline.Reject { + t.Fatalf("flight 2: got %v, want Reject (fresh webhook denies)", a2.Type) + } + if n := callIdx.Load(); n != 2 { + t.Fatalf("webhook call count = %d, want exactly 2 (one per flight)", n) + } +} + +// TestOnRequest_PauseRefreshCacheMidFlight is the regression test for the +// second CodeRabbit failure mode: refreshCache running while a webhook is +// in flight. Under the old code, if the cache entry got deleted (Redis +// missing + pendingWrites == 0), the leader's post-webhook block +// `if cc, ok := p.cache[sessionID]; ok` would skip and pendingResult would +// never be published. Every follower then read `approved=false` from the +// zero-valued counters and got Reject even after an approve. With +// per-flight approval, the outcome rides on the flight object and is +// unaffected by cache-entry deletion. This test races refreshCache against +// a live flight (Redis returns empty → refreshCache deletes) and asserts +// followers still observe the webhook's approve. +func TestOnRequest_PauseRefreshCacheMidFlight(t *testing.T) { + release := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + <-release + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"action":"approve"}`)) + })) + defer srv.Close() + + p := newPausePlugin(t, 3, srv.URL, "deny") + p.gracePeriod = 0 + + // Seed local cache above limit; leave the store empty so refreshCache + // tries to delete the entry (pendingWrites==0 case). + p.mu.Lock() + p.cache["sess"] = &counters{tokens: 0, calls: 3, startedAt: time.Now()} + p.mu.Unlock() + + // Kick leader. + leaderDone := make(chan pipeline.ActionType, 1) + go func() { + a := p.OnRequest(context.Background(), makePctx("sess", 0)) + leaderDone <- a.Type + }() + + // Wait for flight to be in-flight. + deadline := time.Now().Add(2 * time.Second) + for { + p.mu.RLock() + c := p.cache["sess"] + hasFlight := c != nil && c.pendingApproval != nil + p.mu.RUnlock() + if hasFlight { + break + } + if time.Now().After(deadline) { + t.Fatal("leader never populated pendingApproval") + } + time.Sleep(2 * time.Millisecond) + } + + // Kick follower (over-budget still — leader hasn't returned). + followerDone := make(chan pipeline.ActionType, 1) + go func() { + a := p.OnRequest(context.Background(), makePctx("sess", 0)) + followerDone <- a.Type + }() + // Give follower a moment to enter the wait branch. + time.Sleep(20 * time.Millisecond) + + // Now: run refreshCache while the webhook is still blocking. With the + // store empty and pendingWrites==0, this deletes p.cache["sess"] — the + // exact interleave the old code mishandled. + p.refreshCache() + + // Release webhook. + close(release) + + if got := <-leaderDone; got != pipeline.Continue { + t.Errorf("leader: got %v, want Continue (webhook approved)", got) + } + if got := <-followerDone; got != pipeline.Continue { + t.Errorf("follower: got %v, want Continue — this is the bug: cache-entry deletion mid-flight used to make followers observe approved=false", got) + } +} + +// TestShutdown_TimeoutDoesNotCloseStore is the regression test for the +// Shutdown store-close race. On the ctx.Done() path, the old code called +// p.store.Close() while refreshLoop was still running and could be inside +// a HashGet call — concurrent use of a closed client. The fix is to leave +// the store open on the timeout path and let process exit reclaim it. +// This test uses a store that records Close() calls and forces the +// timeout branch by passing an already-expired context. +func TestShutdown_TimeoutDoesNotCloseStore(t *testing.T) { + p := newTestPlugin(0, 3, 0) + // Wire a store that records Close. + rec := &closeRecordingStore{Store: p.store} + p.store = rec + // Start refreshLoop so the ctx.Done() branch is meaningful. + go p.refreshLoop(50 * time.Millisecond) + + // Give the loop a tick to enter its select. + time.Sleep(10 * time.Millisecond) + + // Force the timeout path: expired ctx, so Shutdown selects on ctx.Done() + // before <-p.stopped can fire. + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err := p.Shutdown(ctx) + if err == nil { + t.Fatal("expected non-nil error from Shutdown on canceled ctx") + } + if rec.closes.Load() != 0 { + t.Errorf("store.Close() called %d times on timeout path, want 0 (refreshLoop may still be running)", rec.closes.Load()) + } + + // Let refreshLoop exit cleanly so the test doesn't leak the goroutine — + // stopCh was already closed by Shutdown, so waiting on stopped is enough. + select { + case <-p.stopped: + case <-time.After(2 * time.Second): + t.Fatal("refreshLoop did not exit after stopCh close") + } +} + +// closeRecordingStore wraps a Store and counts Close() calls. +type closeRecordingStore struct { + storage.Store + closes atomic.Int32 +} + +func (s *closeRecordingStore) Close() error { + s.closes.Add(1) + return s.Store.Close() +} From d19e172527967d52e0423e266fa2b17af3575d82 Mon Sep 17 00:00:00 2001 From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:21:28 -0600 Subject: [PATCH 23/29] :recycle::white_check_mark: Update test flakiness Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> --- .../plugins/sessionbudget/plugin_test.go | 206 ++---------------- 1 file changed, 21 insertions(+), 185 deletions(-) diff --git a/authbridge/authlib/plugins/sessionbudget/plugin_test.go b/authbridge/authlib/plugins/sessionbudget/plugin_test.go index 7ef154b32..bcd3f970f 100644 --- a/authbridge/authlib/plugins/sessionbudget/plugin_test.go +++ b/authbridge/authlib/plugins/sessionbudget/plugin_test.go @@ -659,9 +659,8 @@ func TestOnRequest_PauseGraceExpired(t *testing.T) { func TestOnRequest_PausePendingApprovalSentinel(t *testing.T) { var webhookCalls atomic.Int32 started := make(chan struct{}) - // Guard the close so a regression that lets a second webhook fire produces - // a readable test failure ("webhook called 2 times") instead of a - // close-of-closed-channel panic that tears down the whole binary. + // OnceFunc: a regression that lets a second webhook fire produces a + // readable failure instead of close-of-closed-channel panic. signalStart := sync.OnceFunc(func() { close(started) }) proceed := make(chan struct{}) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -710,7 +709,6 @@ func TestOnRequest_PausePendingApprovalSentinel(t *testing.T) { // instead of racing past optimistically. func TestOnRequest_PauseFollowerHonorsDeny(t *testing.T) { started := make(chan struct{}) - // See TestOnRequest_PausePendingApprovalSentinel for rationale. signalStart := sync.OnceFunc(func() { close(started) }) proceed := make(chan struct{}) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -810,189 +808,42 @@ func TestEvaluate_MultipleLimits(t *testing.T) { } } -// TestOnRequest_PauseSecondFlightDoesNotClobberFirst is the regression test -// for the CodeRabbit finding that a new leader could overwrite the previous -// flight's outcome. The old code stored the approval result on the cache -// entry (cc.pendingResult); if a follower was descheduled after receiving -// on the leader's done-channel but before it read pendingResult, a fresh -// breach starting a new flight could rewrite the field and flip the -// follower's decision. Per-flight approval attaches the outcome to the -// flight itself, so this test drives that exact interleave and asserts the -// follower reads the FIRST flight's approve, even though the second flight -// denies. Distinct from PausePendingApprovalSentinel (one flight, both -// approve) and PauseFollowerHonorsDeny (one flight, both deny). -func TestOnRequest_PauseSecondFlightDoesNotClobberFirst(t *testing.T) { - // Two sequential webhook calls with different outcomes. +// Sequential flights with opposite outcomes must not interfere. Regression +// for the pendingResult-on-cache-entry bug: flight 2's deny cannot flip +// flight 1's already-returned approve because outcome rides on the flight. +func TestOnRequest_PauseSequentialFlightsIndependent(t *testing.T) { var callIdx atomic.Int32 - release1 := make(chan struct{}) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { n := callIdx.Add(1) + w.WriteHeader(http.StatusOK) if n == 1 { - <-release1 // leader of flight 1 blocks until we say so - w.WriteHeader(http.StatusOK) w.Write([]byte(`{"action":"approve"}`)) - return + } else { + w.Write([]byte(`{"action":"deny"}`)) } - // Flight 2 denies immediately. - w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"action":"deny"}`)) })) defer srv.Close() p := newPausePlugin(t, 3, srv.URL, "deny") - // grace_period defaults to 5m in newPausePlugin config template? Actually - // newPausePlugin doesn't set pause_grace_period, so Configure defaults it - // to 5m. That would suppress flight 2 entirely. Disable grace explicitly - // by mutating the parsed config after Configure. - p.gracePeriod = 0 + p.gracePeriod = 0 // Configure default is 5m; otherwise flight 2 is short-circuited. p.mu.Lock() p.cache["sess"] = &counters{tokens: 0, calls: 3, startedAt: time.Now()} p.mu.Unlock() - // Kick off flight 1: leader + one follower, both should end up approved. - var wg1 sync.WaitGroup - f1Results := make([]pipeline.ActionType, 2) - // Leader first, so we know the follower joins after pendingApproval is set. - wg1.Add(1) - go func() { - defer wg1.Done() - a := p.OnRequest(context.Background(), makePctx("sess", 0)) - f1Results[0] = a.Type - }() - // Wait until pendingApproval is populated so the second goroutine is a follower. - deadline := time.Now().Add(2 * time.Second) - for { - p.mu.RLock() - c := p.cache["sess"] - hasFlight := c != nil && c.pendingApproval != nil - p.mu.RUnlock() - if hasFlight { - break - } - if time.Now().After(deadline) { - t.Fatal("flight 1 never populated pendingApproval") - } - time.Sleep(2 * time.Millisecond) - } - // Slow follower: capture the flight now, then pretend to be descheduled by - // waiting on the done channel through an indirect path. We simulate that - // by having a real follower goroutine but stalling its post-wait cache - // read via the plugin's own code path — the whole point is that the - // follower must not read a globally-mutable pendingResult. - wg1.Add(1) - go func() { - defer wg1.Done() - a := p.OnRequest(context.Background(), makePctx("sess", 0)) - f1Results[1] = a.Type - }() - - // Let flight 1 complete. - close(release1) - wg1.Wait() - - for i, got := range f1Results { - if got != pipeline.Continue { - t.Errorf("flight 1 request %d: got %v, want Continue (webhook approved)", i, got) - } + if a := p.OnRequest(context.Background(), makePctx("sess", 0)); a.Type != pipeline.Continue { + t.Fatalf("flight 1: got %v, want Continue", a.Type) } - // Simulate lastApprovedAt in the past so flight 2 can actually fire. - p.mu.Lock() - if cc, ok := p.cache["sess"]; ok { - cc.lastApprovedAt = time.Time{} - // Push calls back above the limit so evaluate() returns non-empty again. - cc.calls = 3 - } - p.mu.Unlock() - - // Flight 2 (denies). If old code's shared pendingResult were still in play, - // this deny would clobber flight 1's approve. With per-flight state, flight - // 1's return values are already frozen — this test just proves flight 2 - // stands on its own and returns Reject with its own outcome. - a2 := p.OnRequest(context.Background(), makePctx("sess", 0)) - if a2.Type != pipeline.Reject { - t.Fatalf("flight 2: got %v, want Reject (fresh webhook denies)", a2.Type) - } - if n := callIdx.Load(); n != 2 { - t.Fatalf("webhook call count = %d, want exactly 2 (one per flight)", n) - } -} - -// TestOnRequest_PauseRefreshCacheMidFlight is the regression test for the -// second CodeRabbit failure mode: refreshCache running while a webhook is -// in flight. Under the old code, if the cache entry got deleted (Redis -// missing + pendingWrites == 0), the leader's post-webhook block -// `if cc, ok := p.cache[sessionID]; ok` would skip and pendingResult would -// never be published. Every follower then read `approved=false` from the -// zero-valued counters and got Reject even after an approve. With -// per-flight approval, the outcome rides on the flight object and is -// unaffected by cache-entry deletion. This test races refreshCache against -// a live flight (Redis returns empty → refreshCache deletes) and asserts -// followers still observe the webhook's approve. -func TestOnRequest_PauseRefreshCacheMidFlight(t *testing.T) { - release := make(chan struct{}) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - <-release - w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"action":"approve"}`)) - })) - defer srv.Close() - - p := newPausePlugin(t, 3, srv.URL, "deny") - p.gracePeriod = 0 - - // Seed local cache above limit; leave the store empty so refreshCache - // tries to delete the entry (pendingWrites==0 case). p.mu.Lock() p.cache["sess"] = &counters{tokens: 0, calls: 3, startedAt: time.Now()} p.mu.Unlock() - // Kick leader. - leaderDone := make(chan pipeline.ActionType, 1) - go func() { - a := p.OnRequest(context.Background(), makePctx("sess", 0)) - leaderDone <- a.Type - }() - - // Wait for flight to be in-flight. - deadline := time.Now().Add(2 * time.Second) - for { - p.mu.RLock() - c := p.cache["sess"] - hasFlight := c != nil && c.pendingApproval != nil - p.mu.RUnlock() - if hasFlight { - break - } - if time.Now().After(deadline) { - t.Fatal("leader never populated pendingApproval") - } - time.Sleep(2 * time.Millisecond) - } - - // Kick follower (over-budget still — leader hasn't returned). - followerDone := make(chan pipeline.ActionType, 1) - go func() { - a := p.OnRequest(context.Background(), makePctx("sess", 0)) - followerDone <- a.Type - }() - // Give follower a moment to enter the wait branch. - time.Sleep(20 * time.Millisecond) - - // Now: run refreshCache while the webhook is still blocking. With the - // store empty and pendingWrites==0, this deletes p.cache["sess"] — the - // exact interleave the old code mishandled. - p.refreshCache() - - // Release webhook. - close(release) - - if got := <-leaderDone; got != pipeline.Continue { - t.Errorf("leader: got %v, want Continue (webhook approved)", got) + if a := p.OnRequest(context.Background(), makePctx("sess", 0)); a.Type != pipeline.Reject { + t.Fatalf("flight 2: got %v, want Reject", a.Type) } - if got := <-followerDone; got != pipeline.Continue { - t.Errorf("follower: got %v, want Continue — this is the bug: cache-entry deletion mid-flight used to make followers observe approved=false", got) + if n := callIdx.Load(); n != 2 { + t.Fatalf("webhook calls = %d, want 2", n) } } @@ -1003,35 +854,20 @@ func TestOnRequest_PauseRefreshCacheMidFlight(t *testing.T) { // the store open on the timeout path and let process exit reclaim it. // This test uses a store that records Close() calls and forces the // timeout branch by passing an already-expired context. +// Shutdown's ctx.Done() branch must NOT call store.Close(). No refreshLoop +// runs, so p.stopped never closes and ctx.Done is the only ready select case. func TestShutdown_TimeoutDoesNotCloseStore(t *testing.T) { p := newTestPlugin(0, 3, 0) - // Wire a store that records Close. rec := &closeRecordingStore{Store: p.store} p.store = rec - // Start refreshLoop so the ctx.Done() branch is meaningful. - go p.refreshLoop(50 * time.Millisecond) - - // Give the loop a tick to enter its select. - time.Sleep(10 * time.Millisecond) - // Force the timeout path: expired ctx, so Shutdown selects on ctx.Done() - // before <-p.stopped can fire. ctx, cancel := context.WithCancel(context.Background()) cancel() - err := p.Shutdown(ctx) - if err == nil { + if err := p.Shutdown(ctx); err == nil { t.Fatal("expected non-nil error from Shutdown on canceled ctx") } - if rec.closes.Load() != 0 { - t.Errorf("store.Close() called %d times on timeout path, want 0 (refreshLoop may still be running)", rec.closes.Load()) - } - - // Let refreshLoop exit cleanly so the test doesn't leak the goroutine — - // stopCh was already closed by Shutdown, so waiting on stopped is enough. - select { - case <-p.stopped: - case <-time.After(2 * time.Second): - t.Fatal("refreshLoop did not exit after stopCh close") + if n := rec.closes.Load(); n != 0 { + t.Errorf("store.Close() called %d times on timeout path, want 0", n) } } From 95b2d5098206a8c6c0a72d4cd7d88a372ded62c3 Mon Sep 17 00:00:00 2001 From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:13:32 -0600 Subject: [PATCH 24/29] :bug::white_check_mark: Fix session wedge on panic Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> --- .../authlib/plugins/sessionbudget/plugin.go | 31 +++++++++----- .../plugins/sessionbudget/plugin_test.go | 40 +++++++++++++++++++ 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/authbridge/authlib/plugins/sessionbudget/plugin.go b/authbridge/authlib/plugins/sessionbudget/plugin.go index 49b4d58e7..51b7f7c9d 100644 --- a/authbridge/authlib/plugins/sessionbudget/plugin.go +++ b/authbridge/authlib/plugins/sessionbudget/plugin.go @@ -281,17 +281,28 @@ func (p *SessionBudget) OnRequest(ctx context.Context, pctx *pipeline.Context) p p.log.Info("budget exceeded, requesting approval", "session", sessionID, "reason", reason) - approved := p.callPauseWebhook(ctx, sessionID, reason, &snap) - flight.approved = approved - p.mu.Lock() - if cc, ok := p.cache[sessionID]; ok { - cc.pendingApproval = nil - if approved { - cc.lastApprovedAt = time.Now() + // Deferred cleanup so a panic (or runtime.Goexit) in + // callPauseWebhook can't wedge the session: without this, + // pendingApproval would stay non-nil forever and every + // future request for this session would block on the dead + // flight.done. Order matters: publish outcome to the flight + // object first, then close done (channel-close is the + // happens-before edge for followers), then clear + // pendingApproval under the lock. + approved := false + defer func() { + flight.approved = approved + close(flight.done) + p.mu.Lock() + if cc, ok := p.cache[sessionID]; ok { + cc.pendingApproval = nil + if approved { + cc.lastApprovedAt = time.Now() + } } - } - p.mu.Unlock() - close(flight.done) + p.mu.Unlock() + }() + approved = p.callPauseWebhook(ctx, sessionID, reason, &snap) if approved { pctx.Allow("pause_approved") return pipeline.Action{Type: pipeline.Continue} diff --git a/authbridge/authlib/plugins/sessionbudget/plugin_test.go b/authbridge/authlib/plugins/sessionbudget/plugin_test.go index bcd3f970f..ed7b46ba7 100644 --- a/authbridge/authlib/plugins/sessionbudget/plugin_test.go +++ b/authbridge/authlib/plugins/sessionbudget/plugin_test.go @@ -881,3 +881,43 @@ func (s *closeRecordingStore) Close() error { s.closes.Add(1) return s.Store.Close() } + +// panicTransport panics from RoundTrip so the panic surfaces inside +// p.httpClient.Do(req) — i.e. inside callPauseWebhook after +// pendingApproval is published. Simulates a runtime crash mid-webhook. +type panicTransport struct{} + +func (panicTransport) RoundTrip(*http.Request) (*http.Response, error) { + panic("simulated webhook client panic") +} + +// TestOnRequest_PauseWebhookPanicClearsFlight proves the deferred cleanup +// in OnRequest's pause branch: if callPauseWebhook panics, the defer must +// clear pendingApproval so a follow-up request re-enters as a new leader +// instead of following a dead flight forever. No sleeps, no timers — a +// follow-up call that returned proves the defer ran. +func TestOnRequest_PauseWebhookPanicClearsFlight(t *testing.T) { + p := newPausePlugin(t, 3, "http://unused.invalid", "deny") + p.httpClient = &http.Client{Transport: panicTransport{}} + p.mu.Lock() + p.cache["sess"] = &counters{tokens: 0, calls: 3, startedAt: time.Now()} + p.mu.Unlock() + + call := func() { + defer func() { _ = recover() }() + _ = p.OnRequest(context.Background(), makePctx("sess", 0)) + } + call() + + p.mu.RLock() + pending := p.cache["sess"].pendingApproval + p.mu.RUnlock() + if pending != nil { + t.Fatal("pendingApproval not cleared after panic — session would wedge") + } + + // If the defer had NOT closed flight.done, this second call would + // follower-wait on the dead channel and the test would deadlock + // (caught by go test -timeout). Returning at all proves the fix. + call() +} From dc7e52781a85b40380bdeefe8c21a9a4c8ba74b8 Mon Sep 17 00:00:00 2001 From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:50:32 -0600 Subject: [PATCH 25/29] :art: Log result and other clarifications Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> --- .../authlib/plugins/sessionbudget/plugin.go | 33 +++++++++++++------ .../k8s/pause-webhook-stub.yaml | 2 +- authbridge/docs/session-budget-plugin.md | 6 ++-- 3 files changed, 27 insertions(+), 14 deletions(-) diff --git a/authbridge/authlib/plugins/sessionbudget/plugin.go b/authbridge/authlib/plugins/sessionbudget/plugin.go index 51b7f7c9d..c8b228aa6 100644 --- a/authbridge/authlib/plugins/sessionbudget/plugin.go +++ b/authbridge/authlib/plugins/sessionbudget/plugin.go @@ -69,11 +69,12 @@ type counters struct { // SessionBudget is the plugin state. Redis provides cross-pod durability; // the local cache provides zero-I/O enforcement on the request path. type SessionBudget struct { - cfg config - store storage.Store - log *slog.Logger - httpClient *http.Client - gracePeriod time.Duration + cfg config + store storage.Store + log *slog.Logger + httpClient *http.Client + gracePeriod time.Duration + pauseTimeout time.Duration mu sync.RWMutex cache map[string]*counters @@ -131,8 +132,12 @@ func (p *SessionBudget) Configure(raw json.RawMessage) error { if p.cfg.PauseTimeout == "" { p.cfg.PauseTimeout = "30s" } - if _, err := time.ParseDuration(p.cfg.PauseTimeout); err != nil { + if d, err := time.ParseDuration(p.cfg.PauseTimeout); err != nil { return fmt.Errorf("session-budget: invalid pause_timeout %q: %w", p.cfg.PauseTimeout, err) + } else if d <= 0 { + return fmt.Errorf("session-budget: pause_timeout must be > 0 (got %q)", p.cfg.PauseTimeout) + } else { + p.pauseTimeout = d } if p.cfg.PauseTimeoutAction == "" { p.cfg.PauseTimeoutAction = "deny" @@ -394,8 +399,7 @@ type pauseResponse struct { } func (p *SessionBudget) callPauseWebhook(ctx context.Context, sessionID, reason string, snap *counters) bool { - timeout, _ := time.ParseDuration(p.cfg.PauseTimeout) - ctx, cancel := context.WithTimeout(ctx, timeout) + ctx, cancel := context.WithTimeout(ctx, p.pauseTimeout) defer cancel() body := pauseRequest{ @@ -427,7 +431,8 @@ func (p *SessionBudget) callPauseWebhook(ctx context.Context, sessionID, reason defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - p.log.Warn("pause webhook non-200", "session", sessionID, "status", resp.StatusCode) + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + p.log.Warn("pause webhook non-200", "session", sessionID, "status", resp.StatusCode, "body", string(body)) return p.cfg.PauseTimeoutAction == "allow" } @@ -436,7 +441,15 @@ func (p *SessionBudget) callPauseWebhook(ctx context.Context, sessionID, reason p.log.Warn("pause webhook response decode failed", "session", sessionID, "err", err) return p.cfg.PauseTimeoutAction == "allow" } - return result.Action == "approve" + switch result.Action { + case "approve": + return true + case "deny": + return false + default: + p.log.Warn("pause webhook unknown action; treating as deny", "session", sessionID, "action", result.Action) + return false + } } func (p *SessionBudget) evaluate(c *counters) string { diff --git a/authbridge/demos/session-budget/k8s/pause-webhook-stub.yaml b/authbridge/demos/session-budget/k8s/pause-webhook-stub.yaml index b65fb30b4..878d715eb 100644 --- a/authbridge/demos/session-budget/k8s/pause-webhook-stub.yaml +++ b/authbridge/demos/session-budget/k8s/pause-webhook-stub.yaml @@ -42,7 +42,7 @@ spec: type: RuntimeDefault containers: - name: stub - image: python:3.12-alpine + image: python:3.12-alpine@sha256:d09d15e60962ca365d1cd544a48773bac9d33f2fb1b00f2aa0deec78ade7dc31 securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true diff --git a/authbridge/docs/session-budget-plugin.md b/authbridge/docs/session-budget-plugin.md index 7ceeaeb7c..c8afb82f6 100644 --- a/authbridge/docs/session-budget-plugin.md +++ b/authbridge/docs/session-budget-plugin.md @@ -150,9 +150,9 @@ or **On approval:** the request continues; subsequent requests from the same session skip the webhook for `pause_grace_period` (default `5m`, pod-local — each pod fires one webhook before its own grace kicks in). -Concurrent breaches during an in-flight webhook piggyback on the -pending call and continue optimistically; if that call ultimately -denies, those extra requests have already passed. +Concurrent breaches during an in-flight webhook wait on the pending +call and honor its outcome — all approved together, or all denied +together. **On timeout / non-200 / bad JSON / unreachable:** falls back to `pause_timeout_action` (`deny` returns 403; `allow` continues). If your From e0bf31106aaad696c5d288fec15a5fd2ba71dd1c Mon Sep 17 00:00:00 2001 From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:07:40 -0600 Subject: [PATCH 26/29] :white_check_mark: Test unknown action path Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> --- authbridge/authlib/plugins/sessionbudget/plugin_test.go | 8 ++++++++ authbridge/docs/session-budget-plugin.md | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/authbridge/authlib/plugins/sessionbudget/plugin_test.go b/authbridge/authlib/plugins/sessionbudget/plugin_test.go index ed7b46ba7..b193b31e4 100644 --- a/authbridge/authlib/plugins/sessionbudget/plugin_test.go +++ b/authbridge/authlib/plugins/sessionbudget/plugin_test.go @@ -463,6 +463,14 @@ func TestOnRequest_PauseWebhookFailureFallback(t *testing.T) { w.WriteHeader(http.StatusOK) w.Write([]byte(`not json`)) }, "deny", pipeline.Reject}, + // Well-formed JSON with an action the plugin doesn't recognize + // must deny (protocol-safe default) regardless of pause_timeout_action — + // unlike the transport/decode failures above, this is not a webhook + // outage, so pause_timeout_action does not apply. + {"unknown_action_deny", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"action":"maybe"}`)) + }, "allow", pipeline.Reject}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/authbridge/docs/session-budget-plugin.md b/authbridge/docs/session-budget-plugin.md index c8afb82f6..a61fae604 100644 --- a/authbridge/docs/session-budget-plugin.md +++ b/authbridge/docs/session-budget-plugin.md @@ -215,7 +215,8 @@ docker run -d --name valkey -p 6379:6379 valkey/valkey:latest request — enough to smoke-test `on_exceed: pause` end-to-end: ```bash -docker run -d --name pause-webhook -p 8888:8888 python:3.12-alpine \ +docker run -d --name pause-webhook -p 8888:8888 \ + python:3.12-alpine@sha256:d09d15e60962ca365d1cd544a48773bac9d33f2fb1b00f2aa0deec78ade7dc31 \ python -c "import http.server,json; \ h=type('H',(http.server.BaseHTTPRequestHandler,),{ \ 'do_POST':lambda s:(s.send_response(200), \ From 8ba8a939209cba48c59d11d6d7f2e4fb203d550a Mon Sep 17 00:00:00 2001 From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:41:29 -0600 Subject: [PATCH 27/29] :memo: Clarify plugin catalog description Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> --- authbridge/docs/plugin-catalog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/authbridge/docs/plugin-catalog.md b/authbridge/docs/plugin-catalog.md index 19960266b..bbe29fd68 100644 --- a/authbridge/docs/plugin-catalog.md +++ b/authbridge/docs/plugin-catalog.md @@ -186,7 +186,7 @@ Enforces per-session token, call-count, and duration budgets via Redis. Opt-in a - `refresh_interval` (string) — how often the local cache syncs from Redis. Default `5s`. - `redis_unavailable` (string) — only `fail_open` (default) is implemented; `fail_closed` is rejected at Configure time. -Note that enforcement uses a zero-I/O local cache. After a pod restart, the first request can pass while the cache is cold, before Redis refresh restores the counters. +Cold-cache behavior is mode-dependent. In `deny` and `observe` modes, enforcement uses a zero-I/O local cache: after a pod restart, the first request per session can pass while the cache is cold, before the background Redis refresh restores the counters. In `pause` mode, an incoming request on an empty cache synchronously loads the session's counters from Redis before deciding, so an over-budget session fires the webhook on the first request after a restart. ## `token-broker` From a15a6b32077dc15bc5066d4f0ced675a4e80e3d1 Mon Sep 17 00:00:00 2001 From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:23:15 -0600 Subject: [PATCH 28/29] :memo: Update plugin descriptions Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> --- authbridge/docs/plugin-catalog.md | 4 +++- authbridge/docs/session-budget-plugin.md | 7 ++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/authbridge/docs/plugin-catalog.md b/authbridge/docs/plugin-catalog.md index bbe29fd68..ac7c23e33 100644 --- a/authbridge/docs/plugin-catalog.md +++ b/authbridge/docs/plugin-catalog.md @@ -186,7 +186,9 @@ Enforces per-session token, call-count, and duration budgets via Redis. Opt-in a - `refresh_interval` (string) — how often the local cache syncs from Redis. Default `5s`. - `redis_unavailable` (string) — only `fail_open` (default) is implemented; `fail_closed` is rejected at Configure time. -Cold-cache behavior is mode-dependent. In `deny` and `observe` modes, enforcement uses a zero-I/O local cache: after a pod restart, the first request per session can pass while the cache is cold, before the background Redis refresh restores the counters. In `pause` mode, an incoming request on an empty cache synchronously loads the session's counters from Redis before deciding, so an over-budget session fires the webhook on the first request after a restart. +Cold-cache behavior is mode-dependent; see +[session-budget-plugin.md](session-budget-plugin.md#cold-cache-behavior) +for details. ## `token-broker` diff --git a/authbridge/docs/session-budget-plugin.md b/authbridge/docs/session-budget-plugin.md index a61fae604..da28ae096 100644 --- a/authbridge/docs/session-budget-plugin.md +++ b/authbridge/docs/session-budget-plugin.md @@ -52,7 +52,7 @@ pipeline: | `pause_webhook` | — | URL to POST on breach (required when `on_exceed=pause`) | | `pause_timeout` | `30s` | Max wait for webhook response | | `pause_timeout_action` | `deny` | Fallback on timeout/error: `deny` or `allow` | -| `pause_grace_period` | `5m` | Suppress repeat webhooks after approval | +| `pause_grace_period` | `5m` | Suppress repeat webhooks after approval. `0s` disables the grace window (webhook fires on every breach). | | `session_ttl_seconds` | 7200 | Redis key TTL; must be ≥ `max_duration_seconds` | | `refresh_interval` | `5s` | Local-cache sync interval | | `redis_unavailable` | `fail_open` | Only `fail_open` supported today | @@ -99,6 +99,11 @@ calibrate limits before enforcing: workloads. 4. Flip to `on_exceed: deny` (or `pause`) once confident. +Call accounting is response-driven: `max_calls` is checked against the +count of completed inference responses, so under bursty concurrency +`max_calls` breach logs may lag actual in-flight calls by up to one per +concurrent request. + ### `pause` (HITL webhook) On breach, POST to `pause_webhook` and block the request until the webhook From f1091e34133d12f12ee900929741a751a7eb07dd Mon Sep 17 00:00:00 2001 From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:44:43 -0600 Subject: [PATCH 29/29] :goal_net: Record response body length and validate pause grace period Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> --- authbridge/authlib/plugins/sessionbudget/plugin.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/authbridge/authlib/plugins/sessionbudget/plugin.go b/authbridge/authlib/plugins/sessionbudget/plugin.go index c8b228aa6..e9e9a3d7c 100644 --- a/authbridge/authlib/plugins/sessionbudget/plugin.go +++ b/authbridge/authlib/plugins/sessionbudget/plugin.go @@ -150,6 +150,8 @@ func (p *SessionBudget) Configure(raw json.RawMessage) error { } if d, err := time.ParseDuration(p.cfg.PauseGracePeriod); err != nil { return fmt.Errorf("session-budget: invalid pause_grace_period %q: %w", p.cfg.PauseGracePeriod, err) + } else if d < 0 { + return fmt.Errorf("session-budget: pause_grace_period must be >= 0 (got %q); use \"0s\" to fire the webhook on every breach", p.cfg.PauseGracePeriod) } else { p.gracePeriod = d } @@ -431,8 +433,8 @@ func (p *SessionBudget) callPauseWebhook(ctx context.Context, sessionID, reason defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) - p.log.Warn("pause webhook non-200", "session", sessionID, "status", resp.StatusCode, "body", string(body)) + responseBody, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + p.log.Warn("pause webhook non-200", "session", sessionID, "status", resp.StatusCode, "response_bytes", len(responseBody)) return p.cfg.PauseTimeoutAction == "allow" }