Skip to content

Fix: record the same fields on a denied event from every listener - #1313

Merged
huang195 merged 1 commit into
mainfrom
fix/deny-event-field-parity
Oct 6, 2026
Merged

huang195 merged 1 commit into
mainfrom
fix/deny-event-field-parity

Conversation

@cwiklik

@cwiklik cwiklik commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #936.

The defect

The four recorders that emit a phase:"denied" event — one per direction per listener — grew apart from the same copy-pasted body:

Plugins Identity Duration TLS
extproc inbound + outbound ✅ ✅ ✅ ❌
reverseproxy inbound ❌ ❌ ❌ ✅
forwardproxy outbound ❌ ❌ ❌ ❌

So which fields /v1/sessions published was a property of the listener that served the request rather than of the request. It isn't a two-way split: the forward proxy dropped all four, so an operator reading a denial in proxy-sidecar mode — the default shape, and the one the laptop service runs — could not see who was denied, which plugin denied them, or how long it took.

The fix

All four now call one builder, core/listener/internal/sessionevent.

That location is load-bearing. core/listener/internal/ is importable only from core/listener/..., which is where all four recorders live, so the helper can reach both core/pipeline and core/tlsconfig — without the import cycle that is why pipeline.NewEventTLS takes its SPIFFE ID as a parameter instead of deriving it.

Every field is derived from pctx and absent when pctx does not carry it, so a listener that cannot know something records nothing rather than a zero that reads as a real value. extproc still publishes no TLS, correctly: Envoy terminates the handshake and ext_proc receives the request over gRPC, so there is no client connection to describe.

The callers keep the two decisions that genuinely differ per listener — whether to record at all (a deny from a plugin that appended no Invocation carries no attribution), and which session bucket to append to (inbound keys on the A2A contextId, outbound inherits the most-recently-updated session).

Why the parity suite missed it, and what now catches it

observation excluded Identity, Duration and TLS by construction, and the deny fixture authenticated as nobody and emitted no plugin event. So the three dropped fields compared nil-to-nil and PluginKeys compared empty-to-empty — a fixture only covers what it populates.

The fixture now authenticates (via the spy setting pctx.Identity, which is exactly what jwtvalidation/plugin.go:439 does) and emits a request-phase plugin event. Identity is compared in full; Duration is compared for presence, not equality — it is a live time.Since, so two legs of one fixture legitimately differ and an equality check would flake. Both are also asserted absolutely per listener, because the pairwise diff cannot see a gap that both legs share, and these two recorders were copied from each other. Same blind spot the existing Inference comment in observationDiff documents about itself.

Verified by stashing only the four production call sites:

--- FAIL: TestParity_DenyOnRequest
    listener reverseproxy: missing expected plugin event "parity-spy-a"
    listener reverseproxy: event carried no Identity; the caller authenticated as
      "alice@example.org" and the event cannot be attributed to them
    listener reverseproxy: event carried no Duration; the request completed, so it has one
    parity drift between extproc and reverseproxy: PluginKeys: ["parity-spy-a"] vs null
--- FAIL: TestParity_OutboundDenyOnRequest
    listener forwardproxy: ... the same three

TLS is deliberately excluded from parity

Not deferred — the struct now says why rather than carrying the old "follow-up fixture pass" note. A cross-listener equality check on TLS would assert something false, since extproc legitimately has none; and it would pass here for the wrong reason anyway, because all three drivers run plaintext httptest servers and every leg records nil. Its derivation is unit-tested against a real tls.ConnectionState in listener/internal/sessionevent instead, which is where one can be constructed.

Also

  • Amended the SessionEvent.Duration godoc, which documented only the request and response phases and not the denied one.

Verification

  • go test ./... in core — all packages pass, including all 12 under listener/.
  • cmd/agentop, cmd/cortex, cmd/cortex-envoy — pass.
  • gofmt -l . clean, go vet ./... clean.
  • New parity assertions confirmed failing against the pre-fix call sites on both the inbound and the outbound pair.

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

Summary by CodeRabbit

  • Bug Fixes
    • Denied session records now consistently capture available request details across connection types, including identity, plugin activity, duration, and TLS information.
    • Session records omit details that are unavailable in the request context, while preserving existing denial behavior and session selection. This makes records more complete and consistent when comparing denials across listeners.

The four recorders that emit a phase:"denied" event — one per direction
per listener — grew apart from the same copy-pasted body:

                    Plugins  Identity  Duration  TLS
  extproc in/out       yes      yes       yes     no
  reverseproxy in       no       no        no    yes
  forwardproxy out      no       no        no     no

So which fields /v1/sessions published was a property of the listener
that served the request rather than of the request. The forward proxy
was the worst case, dropping all four: an operator reading a denial in
proxy-sidecar mode could not see who was denied, which plugin denied
them, or how long it took.

Collapse all four onto one builder in listener/internal/sessionevent,
which core/listener/... can import and which can therefore reach both
core/pipeline and core/tlsconfig without the cycle that keeps
pipeline.NewEventTLS taking its SPIFFE ID as a parameter. Each field is
derived from pctx and absent when pctx does not carry it, so a listener
that cannot know something records nothing for it rather than a zero
that reads as real — extproc still publishes no TLS, because Envoy
terminates the handshake and ext_proc never sees the client connection.

The callers keep the two decisions that genuinely differ per listener:
whether to record at all, and which session bucket to append to.

Why the parity suite missed it: its observation struct excluded
Identity, Duration and TLS by construction, and the deny fixture
authenticated as nobody and emitted no plugin event, so the three
dropped fields compared nil to nil and PluginKeys compared empty to
empty. The fixture now authenticates and emits, and the comparison
carries Identity (full) and Duration (presence — it is a live
time.Since, so an equality check across legs would flake). Both are
also asserted absolutely per listener, because the pairwise diff cannot
see a gap that both legs share, and these two recorders were copied
from each other. Verified failing against the pre-fix call sites on
both the inbound and outbound pair.

TLS stays out of parity deliberately, and the struct now says why
rather than calling it a follow-up: cross-listener equality on TLS
would assert something false, and all three drivers run plaintext, so
it would pass for the wrong reason. Its derivation is unit-tested
against a real tls.ConnectionState instead.

Fixes #936

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: cwiklik <cwiklikj@gmail.com>
@coderabbitai

coderabbitai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: d3f527d0-9d35-43a8-b972-fb6b425ecd01
📥 Commits

Reviewing files that changed from the base of the PR and between 2f7bc97 and e629c80.

📒 Files selected for processing (9)
  • core/listener/extproc/server.go
  • core/listener/forwardproxy/server.go
  • core/listener/internal/sessionevent/sessionevent.go
  • core/listener/internal/sessionevent/sessionevent_test.go
  • core/listener/parity/drivers_test.go
  • core/listener/parity/parity_test.go
  • core/listener/parity/spy_test.go
  • core/listener/reverseproxy/server.go
  • core/pipeline/session.go

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

Denial-event construction is centralized in shared helpers used by extproc, forward proxy, and reverse proxy listeners. Parity tests now compare identity and duration presence, and denial fixtures include identity and plugin events.

Changes

Denial Session Event Consistency

Layer / File(s) Summary
Shared denial event construction
core/listener/internal/sessionevent/*, core/pipeline/session.go
New Deny and TLS helpers construct session-event metadata from pipeline context. Tests cover populated and absent context data, and the duration comment documents denied-event timing.
Listener denial and TLS integration
core/listener/extproc/server.go, core/listener/forwardproxy/server.go, core/listener/reverseproxy/server.go
The listener denial paths use sessionevent.Deny. Reverse proxy TLS metadata uses sessionevent.TLS; extproc outbound bucket selection is unchanged.
Denial-event parity validation
core/listener/parity/*
Parity observations include identity and duration presence. Inbound and outbound denial fixtures configure identity and plugin events, and assert those fields across listeners.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix · Severity of issue fixed: Medium

Suggested reviewers: huang195

Merge Risk: ⚪ Minimal · up to e629c

The denial-event consistency change appears mergeable after normal checks.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: making denied-event fields consistent across listeners.
Linked Issues check ✅ Passed Issue #936 requires consistent denied-event fields from pipeline context and parity coverage for identity. All four denial recorders now call sessionevent.Deny; the helper snapshots invocation, plug…
Out of Scope Changes check ✅ Passed The shared TLS helper and its tests support consistent TLS derivation for denied events. Parity fixture changes test the issue’s identified coverage gap. The SessionEvent.Duration documentation chan…
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 15 functions across 9 files.
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@huang195 huang195 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Clean consolidation: all four denial recorders now call one builder, the parity suite gains absolute Identity/Duration checks, and the deny fixtures now authenticate and emit a plugin event, so they actually exercise those fields. No must-fix findings; four nits inline.

What I checked:

  • Mutation: stripping Plugins/Identity/Duration from the forward-proxy call site fails TestParity_OutboundDenyOnRequest with exactly the messages the PR body quotes.
  • No cost double count: denial events now carry the plugin map, which the ledger reads its cost record from, but every deny recorder runs on a request-phase reject and the cost record is only published on the response pass.
  • The PR's factual claims hold: every listener sets StartedAt on entry; pctx.TLS is never set in extproc or the forward proxy; the parity drivers run plaintext; Inbound is the zero Direction; plugin.go:439 is correct.
  • core: build/vet/gofmt clean; listener, pipeline, cost and session tests pass.

Comment thread core/pipeline/session.go
// events it's computed as now - matching-request.At.
// events it's computed as now - matching-request.At. On denied events
// it's DurationSince(pctx.StartedAt) — a denial ends the request, so
// it has a duration even though no response was ever recorded.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: you fixed the Duration doc, but the type-level godoc at line 72 still says a denied event "typically carries only Invocations (+ Identity, Host, Error)". After this PR every listener's denial also carries Plugins, Duration, Client and HTTPMethod/HTTPPath, plus TLS where the listener saw a handshake. That paragraph is where a consumer learns what a denial looks like, so it's worth updating with the field doc.

code = action.Violation.Code
message = action.Violation.Reason
}
return pipeline.SessionEvent{

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: pipeline/snapshot.go:104-109 lists the SnapshotInference exemptions as "the four SessionDenied recorders and forwardproxy.recordTunnelOpened", then says "Anything else that builds a SessionEvent and skips this call is the bug above, not a sixth exemption." After this PR the four recorders don't build an event; this function does, and it skips SnapshotInference. Read literally, that comment now flags sessionevent.Deny as the bug. Suggest naming sessionevent.Deny there, as the one builder the four denial recorders share.

Code: code,
Message: message,
},
Duration: pipeline.DurationSince(pctx.StartedAt),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: in proxy-sidecar mode this line also changes a figure users see. cost/usage/usage.go:1348 counts every event with a Duration of 1ms or more toward latency, denied events included, so reverse- and forward-proxy denials now feed the /v1/usage latency mean in the laptop's Usage pane. A token-exchange deny after a slow IdP failure is one example. This matches extproc, and arguably it's the right number, since the client did wait that long. But it moves a visible metric, so it deserves a line in the PR body.


// Subject / ClientID / Scopes make the spy stand in for an auth
// plugin, which is the only thing that ever populates pctx.Identity
// (jwtvalidation/plugin.go:439 does exactly this assignment). A

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: jwtvalidation/plugin.go:439 is correct today but will go stale with the next edit above it. Naming the place instead (jwt-validation's OnRequest, pctx.Identity = claimsIdentity{...}) won't.

@huang195
huang195 merged commit 7a20565 into main Oct 6, 2026
29 checks passed
@huang195
huang195 deleted the fix/deny-event-field-parity branch October 6, 2026 23:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Reconcile deny-event fields between extproc and proxy listeners

3 participants