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 @@ -1184,6 +1184,13 @@ spec:
description: MaxDataSize limits the size of request/response
data included in audit logs (in bytes).
type: integer
maxDelegationDepth:
default: 10
description: |-
MaxDelegationDepth caps how many nested RFC 8693 "act" entries are
recorded in an audit event's delegationChain. Deeper chains are
truncated (marked with truncated=true). Defaults to 10 when unset.
type: integer
type: object
backends:
description: |-
Expand Down Expand Up @@ -4631,6 +4638,13 @@ spec:
description: MaxDataSize limits the size of request/response
data included in audit logs (in bytes).
type: integer
maxDelegationDepth:
default: 10
description: |-
MaxDelegationDepth caps how many nested RFC 8693 "act" entries are
recorded in an audit event's delegationChain. Deeper chains are
truncated (marked with truncated=true). Defaults to 10 when unset.
type: integer
type: object
backends:
description: |-
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1187,6 +1187,13 @@ spec:
description: MaxDataSize limits the size of request/response
data included in audit logs (in bytes).
type: integer
maxDelegationDepth:
default: 10
description: |-
MaxDelegationDepth caps how many nested RFC 8693 "act" entries are
recorded in an audit event's delegationChain. Deeper chains are
truncated (marked with truncated=true). Defaults to 10 when unset.
type: integer
type: object
backends:
description: |-
Expand Down Expand Up @@ -4634,6 +4641,13 @@ spec:
description: MaxDataSize limits the size of request/response
data included in audit logs (in bytes).
type: integer
maxDelegationDepth:
default: 10
description: |-
MaxDelegationDepth caps how many nested RFC 8693 "act" entries are
recorded in an audit event's delegationChain. Deeper chains are
truncated (marked with truncated=true). Defaults to 10 when unset.
type: integer
type: object
backends:
description: |-
Expand Down
2 changes: 2 additions & 0 deletions docs/arch/02-core-concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,8 @@ See `pkg/audit/mcp_events.go` for complete list of event types.
**Event data:**
- Timestamp, source, outcome
- Subjects (user, session)
- Delegation chain (`delegationChain`) — RFC 8693 `act` claim actors, outermost
first, when the caller used a delegated token
- Target (endpoint, method, resource)
- Request/response data (configurable)
- Duration and metadata
Expand Down
18 changes: 18 additions & 0 deletions docs/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,13 @@ 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"}},
{"sub": "agent-client-2"}
],
"truncated": false
},
"target": {
"endpoint": "/messages",
"method": "POST",
Expand Down Expand Up @@ -517,6 +524,17 @@ 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
- `sub`: Acting party identifier (from the `act` claim's `sub` member)
- `act_claims`: Additional `act` claim members (e.g. `iss`), when present
- `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`
- `target`: Information about the operation target
- `endpoint`: HTTP endpoint path
- `method`: HTTP method
Expand Down
1 change: 1 addition & 0 deletions docs/operator/crd-api.md

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

4 changes: 4 additions & 0 deletions docs/server/docs.go

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

4 changes: 4 additions & 0 deletions docs/server/swagger.json

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

8 changes: 8 additions & 0 deletions docs/server/swagger.yaml

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

94 changes: 83 additions & 11 deletions pkg/audit/auditor.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,18 @@ func (a *Auditor) Close() error {
return nil
}

// SetLogWriterForTest redirects the auditor's log output to w. It is intended
// for tests in other packages (e.g. integration tests) that need to capture
// emitted audit events without reimplementing the auditor.
//
// Deprecated: this is test-only surface on a production type. A future
// NewAuditorWithWriter constructor accepting an explicit io.Writer should
// replace it; keep usage confined to tests.
func (a *Auditor) SetLogWriterForTest(w io.Writer) {
a.auditLogger = NewAuditLogger(w)
a.logWriter = w
}

// isSSETransport checks if the current transport is SSE
func (a *Auditor) isSSETransport() bool {
return a.transportType == types.TransportTypeSSE.String()
Expand Down Expand Up @@ -321,6 +333,9 @@ func (a *Auditor) logAuditEvent(r *http.Request, rw *responseWriter, requestData
// Create the audit event
event := NewAuditEvent(eventType, source, outcome, subjects, component)

// Attach the RFC 8693 delegation chain, if the caller used a delegated token
event.WithDelegationChain(a.extractDelegationChain(r))

// Add target information
target := a.extractTarget(r, eventType)
if len(target) > 0 {
Expand Down Expand Up @@ -542,21 +557,65 @@ func extractSubjectsFromIdentity(identity *auth.Identity) map[string]string {
return subjects
}

// extractDelegationChainFromIdentity returns the RFC 8693 delegation chain
// carried by the identity, re-bounded to maxDepth, or nil when the identity
// has no chain. An already-parsed chain is re-bounded by applying maxDepth to
// its actor list directly; an identity carrying only the raw "act" claim is
// parsed once via auth.ParseDelegationChain. In both cases the configured cap
// is enforced — there is no uncapped "honor as-is" fallback.
func extractDelegationChainFromIdentity(identity *auth.Identity, maxDepth int) *auth.DelegationChain {
if identity == nil {
return nil
}
if chain := identity.DelegationChain; chain != nil && len(chain.Actors) > 0 {
return rebindDelegationChain(chain, maxDepth)
}
if act, ok := identity.Claims["act"]; ok && act != nil {
chain := auth.ParseDelegationChain(act, maxDepth)
if len(chain.Actors) > 0 {
return &chain
}
}
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 {
if maxDepth <= 0 {
maxDepth = auth.DefaultMaxDelegationDepth
}
bound := *chain
if len(bound.Actors) > maxDepth {
bound.DroppedCount += len(bound.Actors) - maxDepth
bound.Actors = bound.Actors[:maxDepth]
bound.Truncated = true
}
return &bound
}

// identityFromRequest resolves the authenticated identity for the request.
// The context value is present when an auth middleware runs OUTSIDE audit;
// the holder covers the audit-wraps-auth arrangement, where the identity
// attached for inner handlers is published back up via auth.WithIdentity.
func identityFromRequest(r *http.Request) (*auth.Identity, bool) {
if identity, ok := auth.IdentityFromContext(r.Context()); ok {
return identity, true
}
if holder, hok := auth.IdentityHolderFromContext(r.Context()); hok && holder.Identity != nil {
return holder.Identity, true
}
return nil, false
}

// extractSubjects extracts subject information from the HTTP request.
func (*Auditor) extractSubjects(r *http.Request) map[string]string {
subjects := make(map[string]string)

// Extract user information from Identity. The context value is present
// when an auth middleware runs OUTSIDE audit; the holder covers the
// audit-wraps-auth arrangement, where the identity attached for inner
// handlers is published back up via auth.WithIdentity.
identity, ok := auth.IdentityFromContext(r.Context())
if !ok {
if holder, hok := auth.IdentityHolderFromContext(r.Context()); hok && holder.Identity != nil {
identity, ok = holder.Identity, true
}
}
if ok {
// Extract user information from Identity.
if identity, ok := identityFromRequest(r); ok {
subjects = extractSubjectsFromIdentity(identity)
}

Expand All @@ -568,6 +627,16 @@ func (*Auditor) extractSubjects(r *http.Request) map[string]string {
return subjects
}

// 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 {
identity, ok := identityFromRequest(r)
if !ok {
return nil
}
return extractDelegationChainFromIdentity(identity, a.config.MaxDelegationDepthOrDefault())
}

// determineComponent determines the component name based on the request.
func (a *Auditor) determineComponent(_ *http.Request) string {
// Use the component from configuration if set
Expand Down Expand Up @@ -752,6 +821,9 @@ func (a *Auditor) logSSEConnectionEvent(r *http.Request, statusCode int) {
// Create the audit event for SSE connection
event := NewAuditEvent(EventTypeSSEConnection, source, a.determineOutcome(statusCode), subjects, component)

// Attach the RFC 8693 delegation chain, if the caller used a delegated token
event.WithDelegationChain(a.extractDelegationChain(r))

// Add target information
target := map[string]string{
"endpoint": r.URL.Path,
Expand Down
Loading
Loading