Documentation
¶
Overview ¶
Package audit provides audit event structures and utilities for the ToolHive ecosystem, ensuring NIST SP 800-53 compliance.
The core type is AuditEvent, which captures the minimal information needed to audit an event in a uniform, serializable format. Use NewAuditEvent to create events with a generated audit ID and UTC timestamp, or NewAuditEventWithID when the caller provides the ID.
Event Structure ¶
Each audit event records:
- Type: a short identifier for what happened (e.g. "mcp_tool_call")
- Source: where the request originated (network address, local process)
- Outcome: success, failure, error, or denied
- Subjects: identity of who triggered the event
- Component: which system component logged the event
- Target: optional target of the operation
- Data: optional extra payload for forensic analysis
Builder Pattern ¶
Events support a fluent builder pattern for optional fields:
event := audit.NewAuditEvent(
"mcp_tool_call",
audit.EventSource{Type: audit.SourceTypeNetwork, Value: "10.0.0.1"},
audit.OutcomeSuccess,
map[string]string{"user": "alice"},
"my-service",
).WithTarget(map[string]string{
"type": "tool",
"name": "calculator",
})
Delegation Chains ¶
When an actor acted on behalf of another actor (RFC 8693 `act` claim), attach a DelegationChain to the event via AuditEvent.WithDelegationChain. The chain is additive to the flat AuditEvent.Subjects map; Subjects remains the backward-compatible identity map and is not modified by setting a chain.
Parse raw `act` claims with ParseDelegationChain, which applies a defensive depth cap (DefaultMaxDelegationDepth, overridable via maxDepth) to bound the size of the retained chain and of the emitted record — not parsing work, since the caller has already decoded the claim — and reports explicit truncation (Truncated/Omitted are never silently omitted). Per-hop identity is the issuer (iss) and subject (sub) pair only: (iss, sub) is the only stable, unique actor identifier (OpenID Connect Core §5.7), and RFC 8693 §6 calls for data minimization. Any extra claims present on a hop are retained in memory for inspection via DelegatedActor.Extra but are never serialized or logged, to avoid leaking Personally Identifiable Information.
The chain is ordered outermost-first: Chain[0] is the current actor and the last entry is the least recent (earliest) actor. The delegating end user is not part of the chain — it is the token's top-level sub, recorded in Subjects. Only the top-level event claims and Chain[0] should drive access-control decisions; prior hops are informational only (RFC 8693 §4.1) and exist for audit.
Parsing never fails: an audit record must be producible for exactly the tokens that violate expectations, since a malformed delegation assertion from a signature-validated token is itself security-relevant (it indicates an issuer bug or tampering), and dropping the record would let malformed input suppress audit (CWE-223, CWE-778). Non-conformant input is recorded on the chain via Malformed and a low-cardinality MalformedReason, with all hops that parsed successfully preserved. Strict rejection of malformed `act` claims belongs on the token-issuance path, not in the audit sink; consumers that alert can key on the malformed field.
Logging ¶
Use AuditEvent.LogTo to emit the event to a log/slog.Logger at a specified level. This produces structured JSON output suitable for audit log collection.
Well-Known Constants ¶
The package defines well-known constants for event types, outcomes, source types, target types, and map keys used in Subjects, Target, Source.Extra, and Metadata.Extra fields. Using these constants ensures consistency across the ToolHive ecosystem.
Stability ¶
This package is Alpha stability. The API may change without notice. See the toolhive-core README for stability level definitions.
Index ¶
- Constants
- type AuditEvent
- func (e *AuditEvent) LogTo(ctx context.Context, logger *slog.Logger, level slog.Level)
- func (e *AuditEvent) WithData(data *json.RawMessage) *AuditEvent
- func (e *AuditEvent) WithDataFromString(data string) *AuditEvent
- func (e *AuditEvent) WithDelegationChain(chain *DelegationChain) *AuditEvent
- func (e *AuditEvent) WithTarget(target map[string]string) *AuditEvent
- type DelegatedActor
- type DelegationChain
- type EventMetadata
- type EventSource
- type MalformedReason
Constants ¶
const ( // OutcomeSuccess indicates the event was successful OutcomeSuccess = "success" // OutcomeFailure indicates the event failed OutcomeFailure = "failure" // OutcomeError indicates the event resulted in an error OutcomeError = "error" // OutcomeDenied indicates the event was denied (e.g., by authorization) OutcomeDenied = "denied" )
Common event outcomes
const ( // SourceTypeNetwork indicates the event came from a network request SourceTypeNetwork = "network" // SourceTypeLocal indicates the event came from a local source SourceTypeLocal = "local" )
Common source types
const ( // ComponentToolHive is the component name for ToolHive API audit events. // Note that events directed for an MCP server will have the name of the // MCP server as the component instead. ComponentToolHive = "toolhive-api" )
Component name for ToolHive
const DefaultMaxDelegationDepth = 16
DefaultMaxDelegationDepth is the default cap on how many delegation hops ParseDelegationChain retains. It is a parse ceiling, not a minting cap: RFC 8693 sets no limit on chain length, so this bounds the size of the retained chain and of the emitted audit record on attacker-influenceable input. It does not bound parsing work — the caller has already decoded the claim. Callers should pass their own minting-side cap as maxDepth when they have one; the default is intentionally larger than any known consumer's minting cap, so that for consumers passing their (smaller) mint cap, Truncated=true is a genuine signal that a token came from outside their own issuance path.
const LevelAudit = slog.Level(2)
LevelAudit is a custom slog level for audit events, sitting between slog.LevelInfo (0) and slog.LevelWarn (4). Logging audit events at this dedicated level lets log infrastructure filter them independently from regular application logs. Pass it as the level argument to AuditEvent.LogTo and as the Level in a handler's slog.HandlerOptions so all ToolHive components share one definition rather than hardcoding the numeric value.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type AuditEvent ¶
type AuditEvent struct {
Metadata EventMetadata `json:"metadata"`
// Type: Defines the type of event that occurred
// This is a small identifier to quickly determine what happened.
// e.g. UserLogin, UserLogout, UserCreate, UserDelete, etc.
Type string `json:"type"`
// LoggedAt: determines when the event occurred.
// Note that this should have sufficient information to authoritatively
// determine the exact time the event was logged at. The output must be in
// Coordinated Universal Time (UTC) format, a modern continuation of
// Greenwich Mean Time (GMT), or local time with an offset from UTC to satisfy
// NIST SP 800-53 requirement AU-8.
LoggedAt time.Time `json:"loggedAt"`
// Source: determines the source of the event.
// Normally, using the IP address of the client, or pod name is sufficient.
// One must be careful of the data that's added here as we don't want to
// leak Personally Identifiable Information.
Source EventSource `json:"source"`
// Outcome: determines whether the event was successful or not, e.g. successful login
// It may also determine if the event was approved or denied.
Outcome string `json:"outcome"`
// Subject: is the identity of the subject of the event.
// e.g. who triggered the event? Additional information
// may be added, such as group membership and/or role
Subjects map[string]string `json:"subjects"`
// Component: allows to determine in which component the event occurred
// (Answering the "Where" question of section c in the NIST SP 800-53
// Revision 5.1 Control AU-3).
Component string `json:"component"`
// Target: Defines where the target of the operation. e.g. the path of
// the REST resource
// (Answering the "Where" question of section c in the NIST SP 800-53
// Revision 5.1 Control AU-3 as well as indicating an entity
// associated for section f).
Target map[string]string `json:"target,omitempty"`
// Data: enhances the audit event with extra information that may be
// useful for forensic analysis.
Data *json.RawMessage `json:"data,omitempty"`
// DelegationChain holds the RFC 8693 `act` delegation chain when the event's
// actor acted on behalf of a prior actor. Additive to Subjects; Subjects
// remains the flat backward-compatible identity map. Omitted when zero
// (no hops, no truncation, no malformation).
DelegationChain *DelegationChain `json:"delegation,omitempty"`
}
AuditEvent represents an audit event. It provides the minimal information needed to audit an event, as well as a uniform format to persist the events in audit logs.
It is highly recommended to use the NewAuditEvent function to create audit events and set the required fields.
func NewAuditEvent ¶
func NewAuditEvent( eventType string, source EventSource, outcome string, subjects map[string]string, component string, ) *AuditEvent
NewAuditEvent returns a new AuditEvent with an appropriately set AuditID and logging time.
func NewAuditEventWithID ¶
func NewAuditEventWithID( auditID string, eventType string, source EventSource, outcome string, subjects map[string]string, component string, ) *AuditEvent
NewAuditEventWithID returns a new AuditEvent with the passed AuditID.
func (*AuditEvent) LogTo ¶
LogTo logs the audit event to the provided slog.Logger using the custom audit level.
func (*AuditEvent) WithData ¶
func (e *AuditEvent) WithData(data *json.RawMessage) *AuditEvent
WithData sets the data of the event.
func (*AuditEvent) WithDataFromString ¶
func (e *AuditEvent) WithDataFromString(data string) *AuditEvent
WithDataFromString sets the data of the event from a string. Note that validating that this is properly JSON-formatted is the responsibility of the caller.
func (*AuditEvent) WithDelegationChain ¶ added in v0.0.35
func (e *AuditEvent) WithDelegationChain(chain *DelegationChain) *AuditEvent
WithDelegationChain sets the RFC 8693 delegation chain for the event's actor. The chain is attached as-is; parsing raw `act` claims is the caller's responsibility (see ParseDelegationChain). Passing a nil or zero chain (no hops, no truncation, no malformation — see DelegationChain.IsZero) clears any previously set chain. A hopless but malformed chain is kept: "the caller asserted delegation we could not read" is audit-relevant and distinct from "no delegation". Subjects is not modified.
func (*AuditEvent) WithTarget ¶
func (e *AuditEvent) WithTarget(target map[string]string) *AuditEvent
WithTarget sets the target of the event.
type DelegatedActor ¶ added in v0.0.35
type DelegatedActor struct {
Issuer string `json:"iss,omitempty"`
Subject string `json:"sub,omitempty"`
// contains filtered or unexported fields
}
DelegatedActor represents a single hop in an RFC 8693 delegation chain.
Per-hop identity is the issuer (iss) and subject (sub) pair only: per OpenID Connect Core §5.7 the (iss, sub) combination is the only stable, unique actor identifier, and RFC 8693 §6 calls for data minimization. Any other claims present on a hop are retained in the in-memory-only extra map (never serialized or logged) to avoid leaking Personally Identifiable Information.
func (DelegatedActor) Extra ¶ added in v0.0.35
func (a DelegatedActor) Extra() map[string]any
Extra returns a shallow copy of the hop's extra (non-standard) claims. Mutating the returned map (adding, deleting, or replacing top-level keys) does not affect the hop's internal state. However, mutating a nested map or slice value may share underlying data with the hop's copy. It returns nil when the hop has no extra claims.
The extra claims are deliberately excluded from JSON and slog output as a PII guard, and may contain sensitive or internal claims (session identifiers, emails, roles). Callers MUST NOT serialize or log the returned map; doing so reintroduces the leak this design prevents.
func (DelegatedActor) String ¶ added in v0.0.35
func (a DelegatedActor) String() string
String implements fmt.Stringer so that fmt-based rendering of a hop — or of any struct containing hops, such as a dereferenced DelegationChain — cannot reach the unexported extra claims through %v/%+v. fmt does not consult log/slog.LogValuer, so the PII guard needs this second, fmt-side door closed as well.
type DelegationChain ¶ added in v0.0.35
type DelegationChain struct {
Chain []DelegatedActor `json:"chain"`
Truncated bool `json:"truncated"`
Omitted int `json:"omitted"`
Malformed bool `json:"malformed"`
MalformedReason MalformedReason `json:"malformedReason,omitempty"`
}
DelegationChain holds a flattened, ordered RFC 8693 delegation chain.
The chain is ordered OUTERMOST-FIRST: Chain[0] is the current actor — the party that presented the token — and the last entry is the least recent (original) actor. The party on whose behalf they acted is NOT part of the chain: per RFC 8693 it is the token's top-level sub, carried by the event's own Subjects map; the chain never repeats it. Per RFC 8693 §4.1, only the top-level event claims and Chain[0] may drive access-control decisions; prior hops are informational only and exist for audit.
Ordering rationale — do not assume, and do not "fix": current-actor-first matches the read order of RFC 8693's nested act structure, and it keeps Chain[0] STABLE UNDER TRUNCATION — truncation drops inner (early) hops, so the one hop that may inform decisions is always at index 0, complete chain or not. Beware that append-style flat formats elsewhere (Envoy XFCC, GCP delegates[]) run the OPPOSITE direction (original-first); consumers correlating across systems must map explicitly rather than by position intuition.
Truncated, Omitted, and Malformed intentionally have no omitempty tag: an incomplete or non-conformant chain is never silently indistinguishable from a complete, well-formed one. When Truncated is true, Omitted reports how many inner hops were dropped to satisfy the maxDepth cap; the outermost hops are always preserved (the current actor is never dropped). When Malformed is true, MalformedReason carries the first conformance violation encountered, and Chain holds the hops that parsed successfully before it.
func ParseDelegationChain ¶ added in v0.0.35
func ParseDelegationChain(raw any, maxDepth int) *DelegationChain
ParseDelegationChain parses a raw RFC 8693 `act` claim into a flattened, outermost-first DelegationChain.
The input is expected to be the decoded JSON value of the `act` claim (i.e. a map[string]any, or any named map type with string keys such as jwt.MapClaims). The parser walks nested `act` claims iteratively, bounded by maxDepth, so pathologically deep input cannot cause a stack overflow.
ParseDelegationChain never fails: it is an audit-side parser, and an audit record must be producible for exactly the tokens that violate expectations (an unreadable delegation assertion is forensically distinct from "no delegation"). Non-conformant input is recorded on the returned chain via Malformed/MalformedReason rather than dropped or returned as an error. Enforcement belongs on the issuance path, not here.
Parsing semantics:
- maxDepth <= 0 uses DefaultMaxDelegationDepth.
- A nil input yields a zero chain (no hops, not malformed): a JSON null or absent act claim asserts no delegation.
- A non-object input (string, number, bool, array) yields no hops and Malformed=true with MalformedReasonActNotObject.
- A map with no iss/sub/act yields one hop with empty Subject/Issuer (sub is conventional, not RFC-mandatory; parsing is tolerant).
- A string sub sets that hop's Subject; a string iss sets Issuer. A non-string sub or iss leaves the field empty, flags the chain malformed, and retains the raw value in the hop's in-memory extra claims — the record shows that an identifier was present but unusable.
- A nested `act` map is recursed: the outer map is Chain[0], its act is Chain[1], and so on. The result is naturally outermost-first.
- A nested `act` that is present but not a map (JSON null excepted, which ends the chain) keeps the hops parsed so far and flags the chain malformed with MalformedReasonNestedActNotObject.
- When the depth equals maxDepth exactly, the full chain is returned with Truncated=false and Omitted=0.
- When the depth exceeds maxDepth, the OUTERMOST maxDepth hops are kept (the current actor is preserved), Truncated=true, and Omitted counts the dropped inner hops. A malformed node past the cap flags Malformed but is not counted as an omitted hop.
- Unknown extra keys on a hop are stored in that hop's in-memory-only extra map (see DelegatedActor.Extra) and are never serialized.
func (*DelegationChain) IsZero ¶ added in v0.0.35
func (c *DelegationChain) IsZero() bool
IsZero reports whether the chain carries no information at all: no hops, no truncation, and no malformation. A zero chain is omitted from JSON and log output; a non-zero chain — including a hopless but malformed one — is always recorded.
func (*DelegationChain) LogValue ¶ added in v0.0.35
func (c *DelegationChain) LogValue() slog.Value
LogValue implements log/slog.LogValuer so that every handler — not just JSONHandler, which happens to skip unexported fields via encoding/json — sees only the promoted iss/sub of each hop. Handing the struct to a handler directly would let a text or other fmt-based handler render the unexported extra claims via %+v, defeating the PII guard. The emitted shape mirrors the struct's JSON tags (including omitempty on iss/sub and malformedReason) so slog and JSON output stay structurally identical.
type EventMetadata ¶
type EventMetadata struct {
// AuditID: is a unique identifier for the audit event.
AuditID string `json:"auditId"`
// Extra allows for including additional information about the event
// that aids in tracking, parsing or auditing
Extra map[string]any `json:"extra,omitempty"`
}
EventMetadata contains metadata about the audit event.
type EventSource ¶
type EventSource struct {
// Type indicates the source type. e.g. Network, File, local, etc.
// The intent is to determine where a request came from.
Type string `json:"type"`
// Value aims to indicate the source of the event. e.g. IP address,
// hostname, etc.
Value string `json:"value"`
// Extra allows for including additional information about the event
// source that aids in tracking, parsing or auditing
Extra map[string]any `json:"extra,omitempty"`
}
EventSource represents the source of an audit event.
type MalformedReason ¶ added in v0.0.35
type MalformedReason string
MalformedReason is a low-cardinality label describing the first RFC 8693 conformance violation encountered while parsing an `act` claim. It is deliberately an enum rather than free text so that it is safe to use as a metric or alerting dimension.
const ( // MalformedReasonActNotObject: the top-level act claim value was not a // JSON object (e.g. a string, number, bool, or array). MalformedReasonActNotObject MalformedReason = "act_not_object" // MalformedReasonNestedActNotObject: a nested act member inside a // delegation hop was not a JSON object. MalformedReasonNestedActNotObject MalformedReason = "nested_act_not_object" // MalformedReasonIssNotString: a hop carried an iss claim that was not a // JSON string. The raw value is retained in that hop's in-memory extra // claims under "iss". MalformedReasonIssNotString MalformedReason = "iss_not_string" // MalformedReasonSubNotString: a hop carried a sub claim that was not a // JSON string. The raw value is retained in that hop's in-memory extra // claims under "sub". MalformedReasonSubNotString MalformedReason = "sub_not_string" )
Malformed reasons reported by ParseDelegationChain. The first violation encountered wins; subsequent violations do not overwrite it.