Fix review findings in the act claim audit capture - #6096
Merged
Conversation
The audit config knob was silently ineffective for any value above 10. claimsToIdentity parses the RFC 8693 "act" claim at the hardcoded DefaultMaxDelegationDepth, and rebindDelegationChain can only shrink an already-parsed actor slice, so it could never recover actors the identity layer dropped. An operator setting maxDelegationDepth to 25 still got 10 actors and no warning, even though the full chain was still present in the raw claim. Re-parse the raw "act" claim at the configured depth when the identity-layer chain was truncated and audit asks for more actors than it kept. The re-parse is trusted only when it yields at least as many actors as are already parsed, so it can widen the chain but never narrow it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SetLogWriterForTest added exported, immediately-deprecated test surface to a production type, and both call sites needed //nolint:staticcheck to mute the deprecation the same change introduced. It also wrote auditLogger with no synchronization on a type whose Middleware reads it from concurrent request goroutines, and never closed the writer it replaced. None of it was necessary: NewAuditorWithTransport already selects its writer from Config.LogFile, so the integration tests can point it at a t.TempDir() file and read the events back. That also converges these tests on the capture pattern the vMCP authz integration tests already use. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
attachDelegation is hand-repeated in all eight WorkflowAuditor Log* methods, but only LogWorkflowStarted and LogStepStarted were covered, so dropping the call from any of the other six -- including every terminal failed/timed-out event, which is what an investigator reads -- would ship silently. Table-drive the assertion over every method, and pin dropped_count and the surviving actor under a configured depth cap. Drop the identity_with_delegation_chain case from ExtractSubjects: extractSubjects never reads DelegationChain, so the case only restated what complete_identity already asserts and passed with the whole feature removed. The behaviour it appeared to cover is now pinned by TestWorkflowAuditor_DelegationChain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two delegation audit tests were ~90-line near-clones differing only in the token minted and the final assertion, so every new case would have been another copy. Move them to their own file and factor the shared setup into signTestJWT, exchangeForDelegatedToken and auditEventForToken. Mechanical: the assertion sets are unchanged, including failure messages, which the helpers carry through as a parameter. The only addition is asserting the auditor's Close error, and the switch from defer to t.Cleanup that the parallel-test rule asks for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs/middleware.md publishes delegation_chain.{actors[].sub,
actors[].act_claims, truncated, dropped_count} as the audit log contract,
but most of it was asserted only as Go struct fields, so a struct-tag
rename would break every downstream consumer with a green suite. Assert
the emitted JSON on the logAuditEvent path, including that dropped_count
is omitted when nothing was dropped.
Every nested act chain in the suite was also hand-built, so
handler.go's act["act"] = priorAct was never executed by a test and the
outermost-first ordering the audit record depends on rested on fixtures
the tests wrote themselves. Drive a real exchange with a may_act-bearing
subject token (RFC 8693 4.4, which this server honors but cannot yet
issue) so the handler produces the outer hop itself, and assert the
ordering end to end.
Also add the missing negative cases on both the POST and stream-open
paths, and a direct test for MaxDelegationDepthOrDefault.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three ways the parser lost forensic evidence on a non-conformant act
claim, all reachable only from a trusted issuer since validation runs
first, but all silent:
A non-string "sub" vanished entirely -- it was captured only when it
type-asserted to string, yet dropped from the claims copy regardless, so
{"sub": 123} recorded an anonymous actor with no trace an identifier had
been present. Keep it under act_claims instead.
RFC 8693 4.1 requires act to be a JSON object. When it was not, the
record was indistinguishable from a token with no delegation at all: a
non-object at the top produced no delegation_chain member, and a
non-object nested deeper reported truncated=false, asserting completeness
while discarding an unreadable remainder. Report it as malformed, and
admit an actorless-but-malformed chain through the guards that previously
required at least one actor.
act_claims also bypassed internalClaims, which keeps tsid out of external
sinks -- Identity.MarshalJSON redacts it from top-level claims but
marshals the delegation chain verbatim. Reuse the same list so the filter
cannot drift.
Actors is now always a non-nil slice: emitting an actorless chain would
otherwise have marshalled "actors": null, a shape no consumer could see
before this change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The delegation chain also reaches admission webhook receivers, because GetPrincipalInfo copies the embedded PrincipalInfo that now carries it -- a user-facing payload change documented nowhere. Note it where the webhook middleware is described. The comment introducing the parse told authorizers to consume the full chain, which RFC 8693 4.1 forbids: an authorizer must consider only the current actor, and prior actors are informational. Nothing reads the chain for authz today, so this only removes a future trap. The architecture doc named the Go struct tag rather than the emitted log key operators actually see. may_act is RFC 8693 4.4, not 4.1; the delegation-consent code cited the act claim's section in four places, and a test added earlier on this branch already cited 4.4 correctly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
maxDataSize is rejected when negative, but maxDelegationDepth was accepted at any value and silently became 10 via the defaulting accessor, so a typo degraded audit coverage instead of failing. The kubebuilder Minimum is what rejects it at admission for VirtualMCPServer; the Validate check catches the CLI and config-file paths, which have no admission layer, and fires at vMCP startup as defense in depth. A nil pointer stays valid -- that is the unset case the accessor and the CRD default already cover -- so this rejects rather than repairs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jhrozek
requested review from
ChrisJBurns,
JAORMX,
amirejaz,
aponcedeleonch,
blkt,
jerm-dro,
rdimitrov,
reyortiz3 and
tgrunnagle
as code owners
July 28, 2026 11:30
JAORMX
previously approved these changes
Jul 28, 2026
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #6096 +/- ##
==========================================
+ Coverage 72.31% 72.35% +0.04%
==========================================
Files 730 731 +1
Lines 75630 75759 +129
==========================================
+ Hits 54689 54813 +124
- Misses 17035 17045 +10
+ Partials 3906 3901 -5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
The malformed, non-string-sub and internal-claim guards took the function to cyclomatic complexity 18, over the configured limit of 15. Moving the per-actor construction out verbatim brings it to 11 and leaves the parser as its two loops plus the malformed bookkeeping. No behavior change; also fixes a codespell hit on a comment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
JAORMX
approved these changes
Jul 28, 2026
This was referenced Jul 28, 2026
JAORMX
added a commit
that referenced
this pull request
Jul 28, 2026
Replaces toolhive's local delegation-chain types with the canonical schema from toolhive-core v0.0.35 (stacklok/toolhive-core#196): pkg/auth/delegation.go deleted in favor of toolhive-core/audit, unified `delegation` wire field across audit events, Identity, and PrincipalInfo (was delegationChain/delegation_chain/actors), typed {iss,sub} hops with act_claims serialization removed, record-and-flag malformed handling with malformedReason, and slog output mirroring the JSON shape. Keeps the toolhive mint cap at 10 (core's 16 is a parse ceiling). Builds on the hardening from #6096; non-schema parts kept. Closes the toolhive side of #6035. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
#6046 landed RFC 8693
actclaim capture in audit events. A review of it afterwards surfaced eleven findings, of which the substantive ones were: a config knob that could not do what it advertised, exported test-only API on a production type, a repeated call that six of eight sites could have dropped silently, a published wire contract asserted only as Go struct fields, and three ways the parser lost forensic evidence on malformed input. This fixes all of them.Closes nothing on its own — this is follow-up on #6035 / #6046.
maxDelegationDepthabove 10 was silently ignored.claimsToIdentityparses at the hardcodedDefaultMaxDelegationDepthand the audit layer could only shrink that result, so an operator setting25got 10 actors and no warning while the full chain sat unread inClaims["act"]. Re-parse the raw claim at the configured depth, guarded so it can only widen, never narrow.SetLogWriterForTestwas exported, immediately deprecated, and needed two//nolint:staticcheck.Config.LogFilealready does the job; the integration tests now point it at at.TempDir()file.attachDelegationis hand-repeated in all eightWorkflowAuditor.Log*methods, but only two were tested — the six uncovered ones included every terminal failed/timed-out event. Table-driven over all eight now.docs/middleware.mdpublishesdelegation_chain.{actors[].sub, actors[].act_claims, truncated, dropped_count}, but most of it was asserted only as Go struct fields, so a struct-tag rename would break every log consumer with a green suite.actchain in the suite was hand-built, sohandler.go'sact["act"] = priorActwas never executed by a test and the outermost-first ordering the audit record depends on rested on fixtures the tests wrote themselves. A real exchange now drives it via amay_act-bearing subject token (RFC 8693 §4.4, which this server honors but cannot yet issue).act.subvanished entirely; a malformedactwas indistinguishable from an absent one;act_claimsbypassed theinternalClaimsfilter that keepstsidout of external sinks — which matters becauseIdentity.MarshalJSONredactstsidfrom top-level claims but marshals the delegation chain verbatim to webhook receivers.maxDelegationDepthhad no lower bound while its siblingmaxDataSizerejects negatives.Type of change
Changes
pkg/audit/auditor.goactclaim at the configured depth; deleteSetLogWriterForTestpkg/auth/delegation.gosub; addMalformed; filterinternalClaims; never emit"actors": nullpkg/auth/context.gopkg/audit/config.gomaxDelegationDepth(Minimum=1+Validate)pkg/authserver/server/tokenexchange/{handler,validator}.gomay_actis §4.4, not §4.1 (comments only)pkg/authserver/integration_delegation_audit_test.godocs/middleware.md,docs/arch/02-core-concepts.mdmalformed, the webhook payload change, and the correct log keydeploy/charts/operator-crds/**,docs/operator/crd-api.md,docs/server/**Minimum=1Test plan
attachDelegationfrom each of the eightLog*methods individually (clean 1:1 failure mapping) and invertingact["act"] = priorActto confirm the multi-hop ordering assertion bites.Does this introduce a user-facing change?
Yes, three:
delegation_chaingains amalformedmember for anactclaim that is present but not a JSON object (RFC 8693 §4.1 requires an object).delegation_chain.actorsis now always an array — an actorless malformed chain would otherwise have emitted"actors": null, a shape no consumer could see before.maxDelegationDepthabove 10 now takes effect; values below 1 are rejected at admission rather than silently becoming 10.Also worth calling out:
PrincipalInfogaineddelegation_chainin #6046, so admission webhook receivers already see it. That was undocumented; this PR documents it.Special notes for reviewers
Overlap with toolhive-core#196. That PR implements the same feature for the shared library and diverges on every JSON key (
delegationvsdelegationChain,chainvsactors,omittedvsdropped_count), on the default depth (16 vs 10), and on whether per-hop claims are serialized at all. That divergence predates this PR — the field names all landed onmainwith #6046. Since core#81 graduated the audit package out of toolhive, whichever schema wins there eventually replaces ours, so it needs settling; I have commented on core#196.The one divergence this PR adds is
malformed: core#196 handles the same input by hard-erroring (ErrNotJSONObject) where we record a flag and keep the partial chain. Worth deciding which behavior we want before either is depended on.Follow-ups filed rather than folded in: #6093 (the auth server can itself mint a §4.1-violating nested
act, which is howmalformedbecomes reachable from our own issuance path), #6094 (WorkflowAuditorleaks the audit log fd), #6095 (extract the eight-way repeated event prologue — the root cause the new test table only guards against).Generated with Claude Code