feat(audit): support RFC 8693 act delegation chains - #196
Conversation
Add a typed, additive delegation-chain representation to the audit package so audit events can capture the actor identity and full delegation chain of a delegated (RFC 8693 act) token, rather than only the terminal subject. - DelegatedActor: one hop (iss+sub only, per RFC 8693 §6; extra claims retained in-memory under json:"-", never serialized or logged). - DelegationChain: flattened, outermost-first (Chain[0]=current actor, the only hop that may drive access control); Truncated/Omitted always emitted (never silent truncation). - ParseDelegationChain: tolerant parse of a raw act claim with a defensive depth cap (DefaultMaxDelegationDepth=16, overridable) that keeps the outermost hops and flags truncation. Sentinel errors ErrNotJSONObject / ErrNestedActNotObject for errors.Is branching. - AuditEvent.DelegationChain (json delegation,omitempty) + WithDelegationChain builder; Subjects map untouched (backward compat). - LogTo emits the chain as a structured slog group (iss+sub per hop). - Fix doc.go example that referenced non-existent constants; add a Delegation Chains section. Purely additive and backward compatible: with no chain set, JSON and log output are identical to before. No new dependencies.
|
Heads up — this overlaps with work that already merged on the toolhive side, and the two schemas currently disagree on every field name. stacklok/toolhive#6046 (merged earlier today) implemented the same feature for Where we diverge:
The good news is that the highest-risk thing agrees: actor ordering is identical — both outermost-first, both keep the outermost hops on truncation, both report not-truncated when the chain is exactly at the cap. That was the one divergence that would have made audit records actively misleading, and it is not one. Two things worth deciding rather than discovering later. 1. The PII question is a real design disagreement, and I think you are right. We serialize arbitrary per-actor 2. Malformed input: flag or error? You return Concretely relevant to (2): stacklok/toolhive#6093 — the toolhive auth server can itself currently mint a non-conformant nested Findings from the #6046 review that may apply here tooListing these so we do not have to find them twice.
Happy to change ours if you would rather core be canonical — nothing outside toolhive depends on our field names yet, so now is the cheapest moment. What I would like to avoid is both shipping and then reconciling after someone has written alerts against one of them. |
jhrozek
left a comment
There was a problem hiding this comment.
Overlaps a merged toolhive PR, and the hard-error contract is doing damage
Two things, then four one-click suggestions that I applied and verified on this branch before proposing them.
1. Schema decision (cross-repo, not code). toolhive#6046 merged this same feature earlier today with a different wire schema:
| toolhive (merged) | this PR | |
|---|---|---|
| event field | delegationChain |
delegation |
| hop list | actors |
chain |
| dropped counter | dropped_count,omitempty |
omitted |
per-hop iss |
folded into act_claims |
typed iss |
| other per-hop claims | serialized as act_claims |
never serialized |
malformed act |
Malformed bool, partial chain |
hard error |
| default depth | 10 | 16 |
| slog shape | one slog.Any blob |
slog.Group, hops.hop_N |
No log consumer can be written against both. Ordering and truncation direction already agree, so this is names plus one design call. Core's audit is toolhive's pkg/audit graduated (core#81), so core's keys win by construction and toolhive's emitted keys will change silently under existing consumers when that migration lands. Nothing outside toolhive depends on either set yet — cheapest moment to converge is now.
On the one substantive difference, your unexported extra should win. toolhive marshals the chain verbatim to admission-webhook receivers, so a foreign act.tsid reached third parties unredacted until #6096 added a filter. Yours makes that class of bug unrepresentable rather than filtered.
2. (nil, err) is the root of most of what I found. Per toolhive#6093, our own token-exchange handler copies a prior act without checking it is an object (actChainDepth returns 0 for a non-map, so the depth gate waves it through) — malformed chains are reachable from our own AS. Because signature and issuer validation run first, a malformed act can only arrive from a trusted issuer, so it flags an issuer bug, which is exactly what an audit trail exists to capture. Returning a nil chain means it vanishes from the record, and the record cannot distinguish "no delegated token" from "token asserted delegation we could not read".
Adopting toolhive's Malformed bool and never returning a nil chain closes six findings at once:
| finding | before | after |
|---|---|---|
chain vanishes on malformed act from a trusted issuer |
(nil, err) |
partial chain + Malformed: true |
depth invariance broken — h1→h2→h3→act:"garbage" yields 2 hops / omitted=2 at maxDepth=2, but a hard error at maxDepth=16 |
record vs nothing, operator's knob decides | same record either way |
omitted counted a bare string as a dropped hop |
omitted=2 for one real dropped hop |
objects only |
json.Marshal(ParseDelegationChain(nil, 0)) → {"chain":null,…}, breaking jq iteration |
null |
[] |
{"sub":"h1","act":null} errored, though 4.1 only makes present-but-not-an-object non-conformant |
error | terminates the chain |
{"sub":123,"iss":["a"]} → empty hop, Extra() nil, evidence gone |
dropped | preserved in Extra() |
dead branches at :129-133, :149-153, :178, :191-195 — collectively every uncovered statement in the file |
unreachable code | gone |
Test churn is two lines. In the table runner, assert.Nil(t, c) becomes:
require.NotNil(t, c, "a chain must be returned even on error")
assert.True(t, c.Malformed, "error path must set Malformed")and the act present but not a map case's errContains becomes "got string". Worth adding cases for act: null, a malformed act past the cap, and {"sub": 123}.
One test gap independent of all the above: the json: keys are asserted nowhere. Mutation-tested on this branch — chain→actors and omitted→dropped_count,omitempty each keep go test ./audit/ green, and the second silently defeats the "truncation is never silent" promise in the DelegationChain doc comment. The slog keys are well pinned by TestAuditEventLogTo_Delegation; it is TestAuditEventJSON_RoundTrip that is tautological, since it marshals and unmarshals the same type. Suggested addition:
var wire map[string]any
require.NoError(t, json.Unmarshal(data, &wire))
d := wire["delegation"].(map[string]any)
_, isArray := d["chain"].([]any)
assert.True(t, isArray, `"chain" must be a JSON array, never null`)
assert.Contains(t, d, "omitted", "omitted must be present even when zero")Also worth reconciling with §1: LogTo emits delegation.hops as an object keyed hop_0…hop_N, while json.Marshal emits delegation.chain as an array. Two shapes for one datum, and ordinal-keyed objects cannot be iterated or filtered in most log query languages.
Verification. I applied all four suggestions below plus the two test edits locally on 585e418: task lint clean, go test -race ./audit/ green, coverage 91.3% → 98.7% (100% on ParseDelegationChain, countRemainingHops, and extraClaims — the dead branches were the uncovered statements), and delegation.go gets 27 lines shorter. Field names are yours to settle per §1; I kept toolhive's Malformed spelling on the assumption that convergence is the goal.
Nothing here is blocking on my account once the schema question has an owner — but I would not merge a second incompatible schema without that answer.
…fy wire shapes Evidence-driven redesign after surveying RFC 8693/9413, NIST SP 800-53 AU-3/AU-5, OWASP ASVS 5.0 V16, MITRE ATT&CK T1562.006, and production audit systems (CloudTrail, GCP audit logs, Kubernetes, Entra ID): - ParseDelegationChain never fails: non-conformant act input is recorded on the chain (Malformed + low-cardinality MalformedReason) with all successfully parsed hops preserved. Dropping the record on malformed attacker-influenceable input would hand token authors an indicator-blocking primitive (T1562.006; CWE-223/778); per RFC 8693 4.1 prior actors are informational-only, so they cannot legitimately fail authorization. Strict rejection belongs on the issuance path. - Sentinel errors removed; Chain is always allocated so JSON emits "chain":[] and never null. - Non-string sub/iss flag the chain and retain the raw value in the in-memory extra claims instead of vanishing silently. - Named map types (e.g. jwt.MapClaims) accepted via reflection fallback. - slog output now marshals the same shape as event JSON (a chain array, not hop_N groups) so the two wire representations always agree. - WithDelegationChain keeps hopless-but-malformed chains (IsZero gate); wire keys pinned by tests against emitted JSON, not struct fields. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Thanks for the thorough writeup — this was exactly the right moment to catch it. Direction first: let's make toolhive-core canonical and fix toolhive to match. The fact that #6046 already merged shouldn't weigh on what the shared library does; and since stacklok/toolhive#6082 means the token-exchange grant currently has no production traffic, the toolhive wire contract is still cheap to change. Since your comment I did a deeper survey of the standards and production landscape (RFC 8693/9413, the transaction-tokens / identity-chaining / actor-chain drafts, NIST SP 800-53 AU-family, OWASP ASVS 5.0 V16, MITRE ATT&CK, CoSAI Agentic IAM v1.0, and the audit-log shapes of CloudTrail, GCP, Kubernetes, Entra, Envoy XFCC, Elasticsearch). The PR is updated (585e418 → 4aad6c9) with the result. Point by point: 1. Malformed input: you were right, and it's stronger than the audit-sink argumentCore now does record-and-flag:
The reason field is an enum rather than free text per OTel 2. act_claims: staying with never-serialize, now with citationsNIST AU-3(1)/(3) (limit audit-record content to what's explicitly needed; limit PII to risk-assessed elements), OTel's header-capture rule ("instrumentations SHOULD require explicit configuration of which headers are captured" — JWT claims and HTTP headers are the same threat class: unbounded attacker-influenceable string maps), and the K8s Promoted 3. Your findings checklist — all landed in 4aad6c9
4. Ordering: outermost-first, now deliberate rather than coincidentalWe already agreed, but the survey turned up a real hazard worth documenting (and I did, on the type): every append-style flat chain out there (Envoy XFCC, GCP 5. Migration path for toolhiveHappy to drive this. It's smaller than the table suggests because #6096 already moved your parser to value-return + |
… clarify sub vs actor split Review feedback from #196: the cap bounds the size of the retained chain and emitted record, not parsing work (the claim is already decoded and countRemainingHops walks the full tail); and Chain[0] is the party that presented the token, while the party on whose behalf it acted lives only in Subjects — the chain never repeats it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
jhrozek
left a comment
There was a problem hiding this comment.
Re-review of 4aad6c9 + c7d4de0 — nearly all of it lands, one new regression
I re-checked every finding against the code rather than the summary, and mutation-tested the parts that claimed new coverage. Verified fixed:
| finding | evidence |
|---|---|
| malformed → record-and-flag | ParseDelegationChain has no error return, sentinels gone, MalformedReason enum, hops before the violation preserved |
| depth invariance | h1→h2→h3→act:"garbage" now yields a record at both maxDepth=2 (2 hops, omitted=1, malformed) and maxDepth=16 (3 hops, malformed) — previously a record vs. a hard error |
inflated omitted |
countRemainingHops returns (int, bool); the non-object is flagged, not counted |
"chain": null |
always allocated, pinned from marshaled bytes |
json: keys unasserted |
re-ran all three mutations — chain→actors (7 failures), omitted→dropped_count,omitempty (fails), flagMalformed neutered (fails). Was green on all three before |
non-string iss/sub |
flags iss_not_string/sub_not_string, raw value retained in the in-memory extra |
act: null |
terminates the chain |
| dead branches / unused import | gone — 100% statement coverage on all eight functions in delegation.go, 99.0% package |
| §6 miscitation, actor/subject conflation | corrected, and OIDC Core §5.7 is the right authority for (iss, sub) |
| depth-cap rationale (c7d4de0) | now says exactly what it does — parse ceiling on retained size, explicitly not work |
task lint clean, go test -race ./audit/ green at c7d4de0.
Two things I'll concede outright. The MITRE T1562.006 framing for record-and-flag is a better argument than the audit-fidelity one I made — "malformed input can suppress the audit trail" is a security property, not a quality preference, and malformedReason as a low-cardinality enum makes it alertable in a way a bare bool isn't. And the ordering-rationale note about Envoy XFCC / GCP delegates[] running original-first is a hazard I did not flag and should have.
The one blocker is a regression introduced by the shape unification. Passing *DelegationChain straight to slog.Any is PII-safe only because JSONHandler happens to route through encoding/json, which skips unexported fields. TextHandler formats with %+v and prints extra in full — including the tsid-class internal claim that never-serialize exists to make unrepresentable. Verified at c7d4de0, details inline. The fix is ~20 lines and keeps both wire shapes identical; I applied it and the suggested test locally before proposing them.
Marking this REQUEST_CHANGES only for that one item — everything else in the delegation work is in better shape than when I first read it.
Passing *DelegationChain to slog.Any was PII-safe only under JSONHandler, which happens to route through encoding/json and skip unexported fields; TextHandler formats with %+v and rendered the extra map in full, including tsid-class internal claims — the exact leak never-serialize exists to prevent, one logging.WithFormat(FormatText) away. DelegationChain now implements slog.LogValuer: handlers only ever see the promoted iss/sub per hop, with omitempty mirrored so slog and JSON shapes stay byte-identical. The parity test now also exercises a text handler and a sub-less hop, both of which fail without the fix. Also: align the package doc with the corrected depth-cap wording, and drop the dead IsEmpty predicate (footgun next to IsZero — a hopless malformed chain is empty yet must still be recorded). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
jhrozek
left a comment
There was a problem hiding this comment.
✅ Everything I raised is resolved — approving
Re-verified eb00bd0 by exercising the code rather than reading the diff:
| item | verification |
|---|---|
LogValue PII guard |
text handler leak=false; mutation — rename LogValue away and the suite fails on all three new assertions |
slog/JSON parity, incl. omitempty |
delegation object byte-identical between json.Marshal(event) and JSONHandler across six fixtures: normal, empty hop, iss-only, non-string sub, non-object, 20-deep truncated |
| text-handler test | adopted verbatim; it is what the mutation trips |
doc.go wording |
package doc and the constant now say the same thing |
IsEmpty deleted |
no references left; test renamed to TestDelegationChain_IsZero |
task lint clean, go test -race ./audit/ green, coverage 99.1% — the sole uncovered block is the pre-existing e.Data path in LogTo, untouched here.
Putting LogValue on the type in delegation.go rather than next to LogTo was the right call, and I'd have argued for it if you hadn't done it — it's the type's contract, not the event's.
Two notes on the earlier rounds, since you asked directly. On (chain, error) vs. no error at all: your (b) is the argument that settles it — if err != nil { return } at a call site is exactly the silent-drop this feature exists to prevent, and a contract that's easy to hold wrong in an audit path is a worse contract. With flag+reason on the struct there is genuinely nothing left to branch on. The %T fidelity loss is real but cheap next to that, and malformedReason recovers most of it as a bounded enum. Keep it as is. On flagging a malformed tail past the cap: agreed, and it's the fix that restored depth invariance — the same token now produces a record with the same malformed signal at any maxDepth, where before it was a record at 2 and a hard error at 16.
One residual below, explicitly non-blocking: the guard is airtight for the shape LogTo passes, and porous for three shapes a consumer can construct. Fine to land as-is and follow up, or take the two suggestions.
Post-approval defense-in-depth from review: DelegatedActor implements fmt.Stringer so %v/%+v of a hop, a dereferenced chain, or any embedding struct cannot reach the unexported extra claims (fmt consults Stringer, not slog.LogValuer); and LogValue nil-guards so slog.Any on a nil chain elides the attribute instead of rendering a recovered-panic trace. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
jhrozek
left a comment
There was a problem hiding this comment.
✅ Re-approving 4eeaad5
Both hardening suggestions landed, and you pinned each one rather than just applying it — TestDelegatedActor_FmtCannotLeakExtra and TestDelegationChain_LogValueNilSafe. Mutation-checked all three guards; each fails the suite when removed:
| guard removed | result |
|---|---|
DelegatedActor.String() |
❌ FAIL |
LogValue nil guard |
❌ FAIL |
LogValue itself (the earlier fix) |
❌ FAIL |
Independent behavioural check at this SHA, not relying on the new tests:
fmt %+v hop / %v value / %+v pointer leak=false {iss:i sub:s}
slog pointer / value / bare hop leak=false
slog nil pointer no attribute emitted
slog delegation == json delegation true
task lint clean, go test -race ./audit/ green, coverage 99.1% with String and LogValue at 100% — the only uncovered block remains the pre-existing e.Data path in LogTo, untouched by this PR.
Nothing outstanding from my side. Three rounds' worth of blocking items — the schema divergence with the merged toolhive#6046, the (nil, err) contract that let a malformed act erase the chain from the record, and the TextHandler PII leak from the shape unification — are all resolved, each with a test that fails if the fix is reverted.
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>
Summary
Adds typed, additive RFC 8693
actdelegation-chain support to theauditpackage, so audit events can capture the actor identity and full delegation chain of a delegated token — not just the terminalsub. This closes the shared-library side of stacklok/toolhive#6035 ("Alice did X" vs "Alice's agent did X on her behalf").This package is intended to be canonical: toolhive's
pkg/audit/pkg/authdelegation schema (stacklok/toolhive#6046, #6096) will be reconciled to match this one at migration time.What changed
DelegatedActor— one hop, identified by typediss+subonly (OIDC Connect Core §5.7: the (iss, sub) pair is the only stable, unique actor identifier; RFC 8693 §6 data minimization). All other per-hop claims are retained in memory only and are never serialized or logged (PII guard; NIST AU-3(1)/(3) audit-record minimization); accessible viaExtra()(shallow copy, documented as must-not-be-logged).DelegationChain— flattened, outermost-first (Chain[0]= current actor, per RFC 8693 §4.1 the only hop that may drive access control; prior hops informational). Current-actor-first keepsChain[0]stable under truncation.Truncated/Omitted/Malformedare always emitted — an incomplete or non-conformant chain is never silently indistinguishable from a complete, well-formed one.ParseDelegationChain(raw, maxDepth) *DelegationChainnever fails. Non-conformant input (non-objectact, non-object nestedact, non-stringiss/sub) setsMalformedplus a low-cardinalityMalformedReasonenum (act_not_object,nested_act_not_object,iss_not_string,sub_not_string) and preserves every hop parsed before the violation. An audit record must be producible for exactly the tokens that violate expectations: dropping the record on malformed attacker-influenceable input is an indicator-blocking primitive (MITRE ATT&CK T1562.006; CWE-223/CWE-778), and per RFC 8693 §4.1 prior actors are informational-only, so a malformed chain cannot legitimately fail authorization — recording it is its only purpose. Strict rejection belongs on the token-issuance path (see Auth server can mint an act claim that violates RFC 8693 §4.1 toolhive#6093).Chainis always allocated — the wire always carries"chain": [], never"chain": null(guarded by a test asserting emitted bytes).jwt.MapClaims) are accepted at any nesting level via a reflection fallback, so programmatically built claims don't silently parse as non-objects.AuditEvent.DelegationChain(json:"delegation,omitempty") +WithDelegationChainbuilder. Zero chains (no hops, no truncation, no malformation) are cleared; hopless-but-malformed chains are kept.Subjectsis untouched — the delegating end user is the token's top-levelsubinSubjects, not a chain entry.LogToemits the chain via its JSON marshaling (slog.Any), so the slog and JSON wire shapes of the same event are structurally identical — one parser serves both (guarded by a shape-equality test).doc.gobug and added a Delegation Chains section documenting ordering, the PII stance, and the malformed-input policy.Backward compatibility
Purely additive — no breaking changes. One additive struct field with
omitempty; no constructor/signature changes;Subjectscontract unchanged. With no chain set, JSON and log output are byte-identical to before (guarded by tests). No new dependencies (stdlib only).Design decisions
userIdentity.type: Unknown. The reason field is an enum, not free text, per OTelerror.typelow-cardinality guidance, so it is safe as a metric/alert dimension.truncated: trueis a genuine signal that a token came from outside its own issuance path.auditpackage, auth-agnostic —ParseDelegationChaintakes an already-decodedany, no JWT dependency.Subjects(avoids smuggled side effects in builders).Test plan
taskgreen (golangci-lint 0 issues,go vetclean, race tests pass);task license-checkpasses. Audit package coverage 99.0%. Tests assert emitted JSON keys, not Go struct fields (tag renames cannot pass with a green suite); pin"chain":[]vsnull; verify slog/JSON shape equality; cover nested parse (0/1/N hops), outermost-first ordering, exactly-at-cap / cap+1 / depth-1000 truncation with exact omitted counts, malformed-past-cap semantics, non-stringsub/iss(flag + in-memory retention), named map types, first-reason-wins, extra-claims PII guards in both JSON and slog output, JSON round-trip, and omit-empty/backward-compat guards.Refs stacklok/toolhive#6035