Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1193,7 +1193,7 @@ spec:
default: 10
description: |-
MaxDelegationDepth caps how many nested RFC 8693 "act" entries are
recorded in an audit event's delegationChain. Deeper chains are
recorded in an audit event's delegation chain. Deeper chains are
truncated (marked with truncated=true). Defaults to 10 when unset.
minimum: 1
type: integer
Expand Down Expand Up @@ -4653,7 +4653,7 @@ spec:
default: 10
description: |-
MaxDelegationDepth caps how many nested RFC 8693 "act" entries are
recorded in an audit event's delegationChain. Deeper chains are
recorded in an audit event's delegation chain. Deeper chains are
truncated (marked with truncated=true). Defaults to 10 when unset.
minimum: 1
type: integer
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1196,7 +1196,7 @@ spec:
default: 10
description: |-
MaxDelegationDepth caps how many nested RFC 8693 "act" entries are
recorded in an audit event's delegationChain. Deeper chains are
recorded in an audit event's delegation chain. Deeper chains are
truncated (marked with truncated=true). Defaults to 10 when unset.
minimum: 1
type: integer
Expand Down Expand Up @@ -4656,7 +4656,7 @@ spec:
default: 10
description: |-
MaxDelegationDepth caps how many nested RFC 8693 "act" entries are
recorded in an audit event's delegationChain. Deeper chains are
recorded in an audit event's delegation chain. Deeper chains are
truncated (marked with truncated=true). Defaults to 10 when unset.
minimum: 1
type: integer
Expand Down
4 changes: 2 additions & 2 deletions docs/arch/02-core-concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -659,8 +659,8 @@ See `pkg/audit/mcp_events.go` for complete list of event types.
**Event data:**
- Timestamp, source, outcome
- Subjects (user, session)
- Delegation chain (`delegation_chain`) — RFC 8693 `act` claim actors, outermost
first, when the caller used a delegated token
- Delegation chain (`delegation`) — RFC 8693 `act` claim hops (issuer/subject
pairs), outermost first, when the caller used a delegated token
- Target (endpoint, method, resource)
- Request/response data (configurable)
- Duration and metadata
Expand Down
62 changes: 35 additions & 27 deletions docs/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ Multiple webhook definitions of the same type run in configuration order. When m

Configuration files may be written in YAML or JSON. Duration values such as `timeout` accept strings like `5s`, and omitted timeouts default to `10s`.

When the caller authenticated with an RFC 8693 delegated token, the request payload sent to webhook receivers includes a `delegation_chain` field on the principal object, with the same shape as the `delegation_chain` field documented under [Audit Middleware](#9-audit-middleware) below.
When the caller authenticated with an RFC 8693 delegated token, the request payload sent to webhook receivers includes a `delegation` field on the principal object, with the same shape as the `delegation` field documented under [Audit Middleware](#9-audit-middleware) below.

Example:

Expand Down Expand Up @@ -483,12 +483,14 @@ Audit events are logged as structured JSON objects:
"client_name": "my-mcp-client",
"client_version": "1.0.0"
},
"delegation_chain": {
"actors": [
{"sub": "agent-client-1", "act_claims": {"iss": "https://auth.example.com"}},
"delegation": {
"chain": [
{"iss": "https://auth.example.com", "sub": "agent-client-1"},
{"sub": "agent-client-2"}
],
"truncated": false
"truncated": false,
"omitted": 0,
"malformed": false
},
"target": {
"endpoint": "/messages",
Expand Down Expand Up @@ -526,29 +528,35 @@ Audit events are logged as structured JSON objects:
- `user`: User display name (from `name` claim, `preferred_username`, or `email`)
- `client_name`: MCP client name (from JWT claims)
- `client_version`: MCP client version (from JWT claims)
- `delegation_chain`: RFC 8693 delegation chain, present only when the caller
authenticated with a delegated token (i.e. the JWT carries an `act` claim)
- `actors`: Acting parties, outermost (most recent) first — `actors[0]` is
the direct delegate that presented the token. Empty array (not omitted)
when `malformed` is `true` and no actor could be parsed at all
- `sub`: Acting party identifier (from the `act` claim's `sub` member)
- `act_claims`: Additional `act` claim members (e.g. `iss`), when present.
May also carry `sub` when the token's `act.sub` was present but not a
string (RFC 7519 §4.1.2 requires a string); such a value is
deliberately not reported as the actor's `sub` and must not be treated
as an actor identifier
- `delegation`: RFC 8693 delegation chain (toolhive-core's canonical schema),
present only when the caller authenticated with a delegated token (i.e. the
JWT carries an `act` claim)
- `chain`: Delegation hops, outermost (most recent) first — `chain[0]` is
the direct delegate that presented the token. Always an array, never
null; empty when `malformed` is `true` and no hop could be parsed at all
- `iss`: The hop's issuer (from the `act` claim's `iss` member), when
present and a string. Omitted otherwise
- `sub`: Acting party identifier (from the `act` claim's `sub` member),
when present and a string. Omitted otherwise. A non-string `iss` or
`sub` leaves the field out and flags the chain `malformed` instead.
Any other `act` claim members are deliberately never serialized
(data minimization per RFC 8693 §6); per OpenID Connect Core §5.7 the
(`iss`, `sub`) pair is the stable actor identifier
- `truncated`: `true` when the chain exceeded the configured maximum depth
(`maxDelegationDepth` in the audit config, default 10) and trailing
actors were dropped
- `dropped_count`: Number of actors dropped due to the depth cap, present
only when `truncated` is `true`
- `malformed`: `true` when the token's `act` claim (at the top level or at
some nesting depth) was present and non-null but not a JSON object, per
RFC 8693 §4.1; present only when `true`. Actors already parsed from
shallower levels of the chain (if any) are still reported alongside it.
When the malformed value sits immediately past the depth cap,
`dropped_count` is deliberately 0 and `truncated` is `false` — unreadable
data past the cap is not a countable actor
(`maxDelegationDepth` in the audit config, default 10) and inner hops
were dropped; the outermost hops — including `chain[0]`, the current
actor — are always kept. Always present
- `omitted`: Number of well-formed hops dropped due to the depth cap.
Always present; `0` when nothing was dropped
- `malformed`: `true` when the token's `act` claim violated RFC 8693
conformance somewhere in the chain (non-object `act`, non-string `iss`
or `sub`). Always present. Hops already parsed before the violation are
still reported alongside it. A malformed value sitting immediately past
the depth cap flags `malformed` but is not a countable hop for `omitted`
- `malformedReason`: Low-cardinality label for the first conformance
violation encountered (`act_not_object`, `nested_act_not_object`,
`iss_not_string`, `sub_not_string`). Present only when `malformed` is
`true`
- `target`: Information about the operation target
- `endpoint`: HTTP endpoint path
- `method`: HTTP method
Expand Down
2 changes: 1 addition & 1 deletion docs/operator/crd-api.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docs/server/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docs/server/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docs/server/swagger.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ require (
github.com/go-jose/go-jose/v3 v3.0.5
github.com/go-jose/go-jose/v4 v4.1.4
github.com/gofrs/flock v0.13.0
github.com/google/cel-go v0.29.2
github.com/google/cel-go v0.30.0
github.com/google/go-cmp v0.7.0
github.com/google/go-containerregistry v0.21.7
github.com/google/uuid v1.6.0
Expand All @@ -49,7 +49,7 @@ require (
github.com/shirou/gopsutil/v4 v4.26.5
github.com/spf13/viper v1.21.0
github.com/stacklok/toolhive-catalog v0.20260727.0
github.com/stacklok/toolhive-core v0.0.34
github.com/stacklok/toolhive-core v0.0.35
github.com/stretchr/testify v1.11.1
github.com/swaggo/swag/v2 v2.0.0-rc5
github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd
Expand Down Expand Up @@ -91,7 +91,7 @@ require (
github.com/go-openapi/runtime/server-middleware v0.30.0 // indirect
github.com/jedisct1/go-minisign v0.0.0-20230811132847-661be99b8267 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/modelcontextprotocol/go-sdk v1.7.0-pre.3 // indirect
github.com/modelcontextprotocol/go-sdk v1.7.0 // indirect
github.com/oklog/ulid/v2 v2.1.1 // indirect
github.com/sassoftware/relic v7.2.1+incompatible // indirect
github.com/segmentio/encoding v0.5.4 // indirect
Expand Down
12 changes: 6 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -365,8 +365,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/cel-go v0.29.2 h1:ZtDxkeiMmz0mxbKDYiNkE5Lk7V5edMRcaaDf2jX002k=
github.com/google/cel-go v0.29.2/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8=
github.com/google/cel-go v0.30.0 h1:ll54AkzKunWkBn9wSoiUXbFZXYZTkdJGNXTBXUoolGo=
github.com/google/cel-go v0.30.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8=
github.com/google/certificate-transparency-go v1.3.3 h1:hq/rSxztSkXN2tx/3jQqF6Xc0O565UQPdHrOWvZwybo=
github.com/google/certificate-transparency-go v1.3.3/go.mod h1:iR17ZgSaXRzSa5qvjFl8TnVD5h8ky2JMVio+dzoKMgA=
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
Expand Down Expand Up @@ -605,8 +605,8 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/modelcontextprotocol/go-sdk v1.7.0-pre.3 h1:SEAY9IduDif4iApnZgpFkjFIdo3askSGZVbZIYyTy6I=
github.com/modelcontextprotocol/go-sdk v1.7.0-pre.3/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44=
github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
github.com/modelcontextprotocol/registry v1.8.0 h1:x/seX0ji4iqRUpSovmkBcGbxfRiZZ7dgPwBcpCJrSTM=
github.com/modelcontextprotocol/registry v1.8.0/go.mod h1:G6AUpTpZSekQvcLl5griUjijEE8vedARE/TyCaHEFdo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
Expand Down Expand Up @@ -789,8 +789,8 @@ github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stacklok/toolhive-catalog v0.20260727.0 h1:5vLZufM+T2BmDg2i2Aj5xxWqYG70vhXXq5REUQHizDo=
github.com/stacklok/toolhive-catalog v0.20260727.0/go.mod h1:G33nbS4PplpBl7lk/hPiIYpsmBl5OATvpohpXJujSfg=
github.com/stacklok/toolhive-core v0.0.34 h1:vhblHGLC+Lp7I+TaIcJsiuzUQo6463o3IooSxIJlhiM=
github.com/stacklok/toolhive-core v0.0.34/go.mod h1:7Cgo1ALJHolkHw/xo7dN9TvFSX0LsKJwXXn0z5zvlyI=
github.com/stacklok/toolhive-core v0.0.35 h1:iwoFxWLZTRzfeUrfpKQljJSwQC+zzjUGLkmOw31QBzw=
github.com/stacklok/toolhive-core v0.0.35/go.mod h1:tEfWyNSeOaiXeUFIykEjQfIu47KN5t8NawJwFnEVv/0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
Expand Down
40 changes: 21 additions & 19 deletions pkg/audit/auditor.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"strings"
"time"

coreaudit "github.com/stacklok/toolhive-core/audit"
"github.com/stacklok/toolhive/pkg/auth"
"github.com/stacklok/toolhive/pkg/mcp"
"github.com/stacklok/toolhive/pkg/transport/types"
Expand Down Expand Up @@ -550,43 +551,44 @@ func extractSubjectsFromIdentity(identity *auth.Identity) map[string]string {
// The identity layer (auth.claimsToIdentity) always parses at
// auth.DefaultMaxDelegationDepth, so a larger maxDepth requires re-parsing the
// raw "act" claim — rebinding an already-parsed chain can only shrink it. The
// re-parse is only trusted when it yields at least as many actors as are
// re-parse is only trusted when it yields at least as many hops as are
// already parsed, so it can widen the chain but never narrow it.
func extractDelegationChainFromIdentity(identity *auth.Identity, maxDepth int) *auth.DelegationChain {
func extractDelegationChainFromIdentity(identity *auth.Identity, maxDepth int) *coreaudit.DelegationChain {
if identity == nil {
return nil
}
rawAct := identity.Claims["act"]
chain := identity.DelegationChain
existingActors := 0
existingHops := 0
if chain != nil {
existingActors = len(chain.Actors)
existingHops = len(chain.Chain)
}
if rawAct != nil && (chain == nil || len(chain.Actors) == 0 ||
(chain.Truncated && maxDepth > len(chain.Actors))) {
if parsed := auth.ParseDelegationChain(rawAct, maxDepth); (len(parsed.Actors) > 0 || parsed.Malformed) &&
len(parsed.Actors) >= existingActors {
return &parsed
if rawAct != nil && (chain.IsZero() ||
(chain.Truncated && maxDepth > existingHops)) {
parsed := coreaudit.ParseDelegationChain(rawAct, maxDepth)
if !parsed.IsZero() && len(parsed.Chain) >= existingHops {
return parsed
}
}
if chain != nil && (len(chain.Actors) > 0 || chain.Malformed) {
if !chain.IsZero() {
return rebindDelegationChain(chain, maxDepth)
}
return nil
}

// rebindDelegationChain applies maxDepth to an already-parsed chain: actors
// beyond the cap are dropped, the chain is marked truncated, and the dropped
// count accounts for both actors truncated at parse time and actors dropped
// by this re-binding.
func rebindDelegationChain(chain *auth.DelegationChain, maxDepth int) *auth.DelegationChain {
// rebindDelegationChain applies maxDepth to an already-parsed chain: hops
// beyond the cap are dropped (keeping the outermost hops, so Chain[0] — the
// current actor — is preserved), the chain is marked truncated, and the
// omitted count accounts for both hops truncated at parse time and hops
// dropped by this re-binding.
func rebindDelegationChain(chain *coreaudit.DelegationChain, maxDepth int) *coreaudit.DelegationChain {
if maxDepth <= 0 {
maxDepth = auth.DefaultMaxDelegationDepth
}
bound := *chain
if len(bound.Actors) > maxDepth {
bound.DroppedCount += len(bound.Actors) - maxDepth
bound.Actors = bound.Actors[:maxDepth]
if len(bound.Chain) > maxDepth {
bound.Omitted += len(bound.Chain) - maxDepth
bound.Chain = bound.Chain[:maxDepth]
bound.Truncated = true
}
return &bound
Expand Down Expand Up @@ -625,7 +627,7 @@ func (*Auditor) extractSubjects(r *http.Request) map[string]string {

// extractDelegationChain extracts the RFC 8693 delegation chain from the
// HTTP request's identity, or nil when there is no identity or no chain.
func (a *Auditor) extractDelegationChain(r *http.Request) *auth.DelegationChain {
func (a *Auditor) extractDelegationChain(r *http.Request) *coreaudit.DelegationChain {
identity, ok := identityFromRequest(r)
if !ok {
return nil
Expand Down
Loading
Loading