Skip to content
Open
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
78 changes: 78 additions & 0 deletions crates/ingress-http/src/layers/audit_header_stripper.rs
Original file line number Diff line number Diff line change
@@ -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<S> Layer<S> for AuditHeaderStripperLayer {
type Service = AuditHeaderStripper<S>;

fn layer(&self, inner: S) -> Self::Service {
AuditHeaderStripper {
inner,
agent_audit: self.agent_audit,
}
}
}

#[derive(Debug, Clone)]
pub struct AuditHeaderStripper<S> {
inner: S,
agent_audit: bool,
}

impl<B, S> Service<Request<B>> for AuditHeaderStripper<S>
where
S: Service<Request<B>>,
{
type Response = S::Response;
type Error = S::Error;
type Future = S::Future;

fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}

fn call(&mut self, mut req: Request<B>) -> 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)
}
}
1 change: 1 addition & 0 deletions crates/ingress-http/src/layers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
8 changes: 7 additions & 1 deletion crates/ingress-http/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ pub struct HyperServerIngress<Schemas, Dispatcher> {
// Parameters to build the layers
schemas: Live<Schemas>,
dispatcher: Dispatcher,
agent_audit: bool,

health: HealthStatus<IngressStatus>,
}
Expand All @@ -76,6 +77,7 @@ where
ingress_options.concurrent_api_requests_limit(),
schemas,
dispatcher,
ingress_options.agent_audit,
health,
)
}
Expand All @@ -91,15 +93,16 @@ where
concurrency_limit: usize,
schemas: Live<Schemas>,
dispatcher: Dispatcher,
agent_audit: bool,
health: HealthStatus<IngressStatus>,
) -> Self {
health.update(IngressStatus::StartingUp);

Self {
listeners,
concurrency_limit,
schemas,
dispatcher,
agent_audit,
health,
}
}
Expand All @@ -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<_>| {
Expand Down Expand Up @@ -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();
Expand Down
13 changes: 13 additions & 0 deletions crates/types/src/config/ingress.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
40 changes: 40 additions & 0 deletions crates/types/src/invocation/audit.rs
Original file line number Diff line number Diff line change
@@ -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-";
1 change: 1 addition & 0 deletions crates/types/src/invocation/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
59 changes: 59 additions & 0 deletions release-notes/unreleased/agent-audit-headers.md
Original file line number Diff line number Diff line change
@@ -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.
Loading