diff --git a/crates/ingress-http/src/layers/audit_header_stripper.rs b/crates/ingress-http/src/layers/audit_header_stripper.rs new file mode 100644 index 0000000000..9c8d9b6b7b --- /dev/null +++ b/crates/ingress-http/src/layers/audit_header_stripper.rs @@ -0,0 +1,78 @@ +// Copyright (c) 2023 - 2026 Restate Software, Inc., Restate GmbH. +// All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +//! Tower middleware that strips `x-restate-audit-*` headers at ingress when +//! agent audit is disabled, preventing untrusted clients from injecting fake +//! audit context. +//! +//! When `agent_audit` is `true` the layer is a transparent pass-through. + +use http::Request; +use std::task::{Context, Poll}; +use tower::{Layer, Service}; + +use restate_types::invocation::audit::HEADER_PREFIX; + +/// Layer that strips `x-restate-audit-*` request headers unless agent audit is enabled. +#[derive(Debug, Clone)] +pub struct AuditHeaderStripperLayer { + agent_audit: bool, +} + +impl AuditHeaderStripperLayer { + pub fn new(agent_audit: bool) -> Self { + Self { agent_audit } + } +} + +impl Layer for AuditHeaderStripperLayer { + type Service = AuditHeaderStripper; + + fn layer(&self, inner: S) -> Self::Service { + AuditHeaderStripper { + inner, + agent_audit: self.agent_audit, + } + } +} + +#[derive(Debug, Clone)] +pub struct AuditHeaderStripper { + inner: S, + agent_audit: bool, +} + +impl Service> for AuditHeaderStripper +where + S: Service>, +{ + type Response = S::Response; + type Error = S::Error; + type Future = S::Future; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, mut req: Request) -> Self::Future { + if !self.agent_audit { + let audit_keys: Vec<_> = req + .headers() + .keys() + .filter(|name| name.as_str().starts_with(HEADER_PREFIX)) + .cloned() + .collect(); + for key in audit_keys { + req.headers_mut().remove(&key); + } + } + self.inner.call(req) + } +} diff --git a/crates/ingress-http/src/layers/mod.rs b/crates/ingress-http/src/layers/mod.rs index 1facd6d566..74502afa37 100644 --- a/crates/ingress-http/src/layers/mod.rs +++ b/crates/ingress-http/src/layers/mod.rs @@ -8,5 +8,6 @@ // the Business Source License, use of this software will be governed // by the Apache License, Version 2.0. +pub mod audit_header_stripper; pub mod load_shed; pub mod tracing_context_extractor; diff --git a/crates/ingress-http/src/server.rs b/crates/ingress-http/src/server.rs index 733bcd5284..402ced552b 100644 --- a/crates/ingress-http/src/server.rs +++ b/crates/ingress-http/src/server.rs @@ -54,6 +54,7 @@ pub struct HyperServerIngress { // Parameters to build the layers schemas: Live, dispatcher: Dispatcher, + agent_audit: bool, health: HealthStatus, } @@ -76,6 +77,7 @@ where ingress_options.concurrent_api_requests_limit(), schemas, dispatcher, + ingress_options.agent_audit, health, ) } @@ -91,15 +93,16 @@ where concurrency_limit: usize, schemas: Live, dispatcher: Dispatcher, + agent_audit: bool, health: HealthStatus, ) -> Self { health.update(IngressStatus::StartingUp); - Self { listeners, concurrency_limit, schemas, dispatcher, + agent_audit, health, } } @@ -116,11 +119,13 @@ where concurrency_limit, schemas, dispatcher, + agent_audit, health, } = self; // Prepare the handler let service = ServiceBuilder::new() + .layer(layers::audit_header_stripper::AuditHeaderStripperLayer::new(agent_audit)) .layer( TraceLayer::new_for_http() .make_span_with(|request: &Request<_>| { @@ -404,6 +409,7 @@ mod tests { Semaphore::MAX_PERMITS, Live::from_value(mock_schemas()), Arc::new(mock_request_dispatcher), + false, health.ingress_status(), ); TaskCenter::spawn(TaskKind::SystemService, "ingress", ingress.run()).unwrap(); diff --git a/crates/types/src/config/ingress.rs b/crates/types/src/config/ingress.rs index c9c6210f47..5c5914af3a 100644 --- a/crates/types/src/config/ingress.rs +++ b/crates/types/src/config/ingress.rs @@ -53,6 +53,19 @@ pub struct IngressOptions { /// Settings for the ingestion client /// Currently only used by the Kafka ingress and the admin API. pub ingestion: IngestionOptions, + + /// # Agent audit + /// + /// When `true`, `x-restate-audit-triggered-by` and `x-restate-audit-conversation-id` + /// headers are forwarded to handlers, enabling agent audit context propagation. + /// When `false` (default), these headers are stripped at ingress to prevent + /// untrusted clients from injecting fake audit context. + /// + /// See [`restate_types::invocation::audit`] for the well-known header constants. + /// + /// Since v1.7.0 + #[serde(default)] + pub agent_audit: bool, } impl IngressOptions { diff --git a/crates/types/src/invocation/audit.rs b/crates/types/src/invocation/audit.rs new file mode 100644 index 0000000000..df3c6a5c34 --- /dev/null +++ b/crates/types/src/invocation/audit.rs @@ -0,0 +1,40 @@ +// Copyright (c) 2023 - 2026 Restate Software, Inc., Restate GmbH. +// All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +//! Well-known header names for agent audit context propagation. +//! +//! These headers carry the human-origin identity of a call chain across agent hops. +//! Restate does not auto-propagate them — the calling service/SDK is responsible +//! for re-attaching them on every outbound call, the same discipline as W3C `traceparent`. +//! +//! All other fields in a full audit trace (`agent_id`, `workflow_id`, `workflow_step`, +//! `agent_type`, `agent_version`) are already derivable from Restate's existing +//! invocation context and do not require explicit headers. +//! +//! # Enabling +//! +//! These headers are stripped at ingress by default. Set `ingress.agent-audit: true` +//! in the Restate configuration to allow them through to handlers. + +/// Header carrying the human principal that originally triggered this call chain. +/// +/// Value: opaque string, e.g. `"user@gov.sg"` +/// Propagation: caller must re-attach on every outbound call. +pub const TRIGGERED_BY: &str = "x-restate-audit-triggered-by"; + +/// Header carrying the human session/conversation that originated this call chain. +/// +/// Value: opaque string, e.g. `"sess_001"` +/// Propagation: caller must re-attach on every outbound call. +pub const CONVERSATION_ID: &str = "x-restate-audit-conversation-id"; + +/// Prefix shared by all audit headers. Used to strip them at ingress when +/// `ingress.agent-audit` is disabled. +pub const HEADER_PREFIX: &str = "x-restate-audit-"; diff --git a/crates/types/src/invocation/mod.rs b/crates/types/src/invocation/mod.rs index 9307911c53..7834017df7 100644 --- a/crates/types/src/invocation/mod.rs +++ b/crates/types/src/invocation/mod.rs @@ -10,6 +10,7 @@ //! This module contains all the core types representing a service invocation. +pub mod audit; pub mod client; use crate::errors::InvocationError; diff --git a/release-notes/unreleased/agent-audit-headers.md b/release-notes/unreleased/agent-audit-headers.md new file mode 100644 index 0000000000..46e92be582 --- /dev/null +++ b/release-notes/unreleased/agent-audit-headers.md @@ -0,0 +1,59 @@ +# Agent Audit Context Headers + +## New Feature + +### What Changed + +Added opt-in support for agent audit context propagation via well-known HTTP headers. + +Two header constants are now defined in `restate_types::invocation::audit`: + +- `x-restate-audit-triggered-by` — the human principal that triggered the call chain +- `x-restate-audit-conversation-id` — the human session/conversation that originated the chain + +A new ingress config option `agent-audit` (default: `false`) controls whether these headers +are forwarded to handlers. When disabled, the headers are stripped at ingress to prevent +untrusted clients from injecting fake audit context. + +### Why This Matters + +When Restate orchestrates multi-agent AI workflows, there is no standard way to carry +the originating user identity and session across agent hops. These headers provide that +mechanism. All other audit fields (`agent_id`, `workflow_id`, `workflow_step`, `agent_type`) +are already derivable from Restate's existing invocation context. + +### Impact on Users + +- **Existing deployments**: No impact. The feature is disabled by default and the headers + are stripped silently. +- **New deployments using agent workflows**: Enable the feature and attach the headers + on every outbound call from your handlers. + +### Migration Guidance + +Enable in your Restate configuration: + +```toml +[ingress] +agent-audit = true +``` + +Then in your handler, attach the headers on every outbound service call: + +```python +AUDIT_TRIGGERED_BY = "x-restate-audit-triggered-by" +AUDIT_CONVERSATION_ID = "x-restate-audit-conversation-id" + +await ctx.service_call( + other_agent.handle, + arg=payload, + headers={ + AUDIT_TRIGGERED_BY: req.headers.get(AUDIT_TRIGGERED_BY), + AUDIT_CONVERSATION_ID: req.headers.get(AUDIT_CONVERSATION_ID), + } +) +``` + +The header values propagate unchanged from ingress all the way through the agent chain. +Use `ctx.invocation_id()` as `workflow_id` and `ctx.key()` as `agent_id` — these are +already provided by Restate at no extra cost.