praxis_policy_core/hooks/payload.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 Praxis Contributors
3
4// PluginPayload trait and Extensions stub.
5//
6// PluginPayload is the base trait for all hook payloads. Every payload
7// implements it, giving the executor and registry a common bound for type
8// safety.
9//
10// The trait is object-safe — the executor works with `Box<dyn PluginPayload>`
11// instead of `Box<dyn Any>`, catching type errors at compile time.
12// Downcasting to concrete types uses the `as_any()` method.
13//
14// Extensions is the typed container for all message extensions
15// (security, delegation, HTTP, meta, etc.). It is always passed
16// as a separate parameter to handlers — never inside the payload.
17// This allows per-plugin capability filtering and independent
18// modification without copying the payload.
19
20use std::any::Any;
21use std::fmt;
22
23// Re-export Extensions and OwnedExtensions from the extensions module.
24// These are the typed containers for all extension data. They live in
25// extensions/container.rs but are re-exported here for backward
26// compatibility with existing code that imports from hooks::payload.
27pub use crate::extensions::{Extensions, Guarded, MetaExtension, OwnedExtensions, WriteToken};
28
29/// Base trait for all hook payloads.
30///
31/// payload type in the framework implements this trait. The executor
32/// and registry use `Box<dyn PluginPayload>` (not `Box<dyn Any>`)
33/// for type-safe dispatch.
34///
35/// The trait is **object-safe** — it can be used behind `Box`, `&`,
36/// and `Arc` without knowing the concrete type. This is achieved by
37/// providing `clone_boxed()` instead of requiring `Clone` directly
38/// (which is not object-safe), and `as_any()` / `as_any_mut()` for
39/// downcasting to the concrete type when needed.
40///
41/// Payloads are:
42/// - Cloneable via `clone_boxed()` — the executor uses this for COW
43/// when a modifying plugin (Sequential or Transform) needs ownership.
44/// - `Send + Sync` — payloads may be shared across threads for
45/// Concurrent mode plugins.
46/// - `'static` — payloads must be owned types (no borrowed references).
47///
48/// Extensions are **not** part of the payload. They are passed as a
49/// separate `&Extensions` parameter to handlers.
50///
51/// # Examples
52///
53/// ```
54/// use praxis_policy_core::hooks::payload::PluginPayload;
55///
56/// #[derive(Debug, Clone)]
57/// struct RateLimitPayload {
58/// client_id: String,
59/// request_count: u64,
60/// }
61///
62/// impl PluginPayload for RateLimitPayload {
63/// fn clone_boxed(&self) -> Box<dyn PluginPayload> {
64/// Box::new(self.clone())
65/// }
66/// fn as_any(&self) -> &dyn std::any::Any { self }
67/// fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
68/// }
69/// ```
70pub trait PluginPayload: Send + Sync + 'static {
71 /// Clone this payload into a new `Box<dyn PluginPayload>`.
72 ///
73 /// Used by the executor for copy-on-write: read-only modes borrow
74 /// the payload, modifying modes receive a clone via this method.
75 fn clone_boxed(&self) -> Box<dyn PluginPayload>;
76
77 /// Downcast to a concrete type via `&dyn Any`.
78 ///
79 /// Used by typed handler wrappers to recover the concrete payload
80 /// type from `Box<dyn PluginPayload>`.
81 fn as_any(&self) -> &dyn Any;
82
83 /// Downcast to a concrete type via `&mut dyn Any`.
84 fn as_any_mut(&mut self) -> &mut dyn Any;
85
86 /// Canonical bytes of this payload for content-addressed audit
87 /// provenance, or `None` for a payload that cannot or should not be
88 /// serialized, which is the default.
89 ///
90 /// The bytes feed a content hash and only the digest is kept, never the
91 /// bytes, so a node's provenance is recorded without re-spilling content
92 /// a redaction plugin exists to remove. Computed only when content
93 /// provenance is enabled, so the default costs nothing.
94 ///
95 /// What a consumer may assume: `impl_plugin_payload!(_, audit_serialize)`
96 /// derives this through [`canonical_audit_bytes`], which sorts object keys
97 /// at every level, so identical content gives identical bytes across runs
98 /// and processes and two equal digests mean the same content within a
99 /// deployment.
100 ///
101 /// The sorting is explicit rather than inherited from `serde_json::Map`.
102 /// This workspace resolves `serde_json` with `preserve_order`, so its
103 /// `Map` keeps insertion order, and a payload holding a `HashMap` would
104 /// otherwise serialize differently run to run and read as changed content
105 /// when nothing changed.
106 ///
107 /// This is sorted-key JSON, not RFC 8785. Number formatting follows
108 /// `serde_json` and is stable within a version but is not guaranteed
109 /// across toolchains, so do not treat a digest as a cross-toolchain
110 /// canonical form. A hand-written `audit_bytes` must preserve the same
111 /// property or its hashes will not compare.
112 fn audit_bytes(&self) -> Option<Vec<u8>> {
113 None
114 }
115}
116
117/// Canonical bytes for a serializable payload: JSON with object keys sorted
118/// at every level.
119///
120/// Sorting cannot be left to `serde_json::Map`. This workspace resolves
121/// `serde_json` with `preserve_order`, so a `Map` keeps insertion order and a
122/// payload holding a `HashMap` would encode differently on each run. Two
123/// digests of identical content would then differ, and a consumer would read
124/// a change that never happened.
125///
126/// Returns `None` when the value does not serialize.
127#[must_use]
128pub fn canonical_audit_bytes<T: serde::Serialize>(value: &T) -> Option<Vec<u8>> {
129 fn sort_keys(value: serde_json::Value) -> serde_json::Value {
130 match value {
131 serde_json::Value::Object(map) => {
132 let mut entries: Vec<(String, serde_json::Value)> = map.into_iter().collect();
133 entries.sort_by(|a, b| a.0.cmp(&b.0));
134 serde_json::Value::Object(
135 entries
136 .into_iter()
137 .map(|(k, v)| (k, sort_keys(v)))
138 .collect(),
139 )
140 },
141 serde_json::Value::Array(items) => {
142 serde_json::Value::Array(items.into_iter().map(sort_keys).collect())
143 },
144 other => other,
145 }
146 }
147
148 let value = serde_json::to_value(value).ok()?;
149 serde_json::to_vec(&sort_keys(value)).ok()
150}
151
152/// The key audit content provenance digests a payload under.
153///
154/// Two digests are comparable only when taken under the same key, so every
155/// digest names its key: `hmac-sha256:<key_id>:<hex>` for a keyed digest and
156/// `sha256:<hex>` for an unkeyed one. A reader comparing digests across a key
157/// rotation sees two key ids and knows not to compare them, rather than
158/// reading the rotation as the content changing.
159///
160/// The key is deployment-scoped rather than per record so that equal content
161/// gives equal digests across requests, which is what lets a reader spot the
162/// same payload replayed. A per-record salt would keep the within-record
163/// comparison and lose that.
164#[derive(Debug, Clone)]
165pub enum ContentKey {
166 /// Plain SHA-256, chosen explicitly with `content_provenance_key: unkeyed`.
167 ///
168 /// Anyone holding a record can test a guess at the payload against it, so
169 /// a short or templated message carrying a redacted value can be
170 /// enumerated back out of the digest.
171 Unkeyed,
172 /// HMAC-SHA256 under an operator secret. Read on every digest, so a
173 /// refresh that rotates the secret takes effect without a reload.
174 Keyed(crate::secrets::SecretRef),
175}
176
177/// The shortest secret accepted as a content provenance key: the HMAC-SHA256
178/// block of strength. A shorter key is the weak point an attacker brute-forces
179/// instead of the content.
180pub const MIN_CONTENT_KEY_BYTES: usize = 32;
181
182/// What the key id is the MAC of. Fixed, so the id is stable across restarts
183/// and changes only when the key does.
184const KEY_ID_LABEL: &[u8] = b"praxis-policy/content-provenance/key-id";
185
186impl ContentKey {
187 /// A keyed digest under `secret`.
188 ///
189 /// # Errors
190 ///
191 /// Returns a message naming the minimum when the secret is shorter than
192 /// [`MIN_CONTENT_KEY_BYTES`].
193 pub fn keyed(secret: crate::secrets::SecretRef) -> Result<Self, String> {
194 let len = secret.get().len();
195 if len < MIN_CONTENT_KEY_BYTES {
196 return Err(format!(
197 "the content provenance key is {len} bytes; it needs at least \
198 {MIN_CONTENT_KEY_BYTES}"
199 ));
200 }
201 Ok(Self::Keyed(secret))
202 }
203
204 /// Digest `bytes`, prefixed with the scheme and, for a keyed digest, the
205 /// key id.
206 ///
207 /// `None` only if the HMAC cannot be initialized, which it accepts any key
208 /// length for. Handled rather than asserted, since a panic here would take
209 /// the request down over an audit field.
210 #[must_use]
211 pub fn digest(&self, bytes: &[u8]) -> Option<String> {
212 use hmac::{Hmac, KeyInit as _, Mac as _};
213 use sha2::{Digest as _, Sha256};
214
215 match self {
216 Self::Unkeyed => Some(format!("sha256:{}", hex(&Sha256::digest(bytes)))),
217 Self::Keyed(secret) => {
218 let key = secret.get();
219 let mac = |data: &[u8]| {
220 let mut m = <Hmac<Sha256>>::new_from_slice(key.as_bytes()).ok()?;
221 m.update(data);
222 Some(m.finalize().into_bytes())
223 };
224 let id = mac(KEY_ID_LABEL)?;
225 Some(format!(
226 "hmac-sha256:{}:{}",
227 hex(id.get(..8).unwrap_or_default()),
228 hex(&mac(bytes)?)
229 ))
230 },
231 }
232 }
233}
234
235fn hex(bytes: &[u8]) -> String {
236 use std::fmt::Write as _;
237 let mut s = String::with_capacity(bytes.len() * 2);
238 for b in bytes {
239 let _ = write!(s, "{b:02x}");
240 }
241 s
242}
243
244impl fmt::Debug for dyn PluginPayload {
245 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
246 f.write_str("dyn PluginPayload")
247 }
248}
249
250/// Implements `PluginPayload` for a type that is `Clone + Send + Sync + 'static`.
251///
252/// Saves boilerplate — instead of writing the three methods manually,
253/// just invoke this macro:
254///
255/// ```
256/// use praxis_policy_core::impl_plugin_payload;
257///
258/// #[derive(Debug, Clone)]
259/// struct MyPayload { value: i32 }
260///
261/// impl_plugin_payload!(MyPayload);
262/// ```
263#[macro_export]
264macro_rules! impl_plugin_payload {
265 ($ty:ty) => {
266 impl $crate::hooks::payload::PluginPayload for $ty {
267 fn clone_boxed(&self) -> Box<dyn $crate::hooks::payload::PluginPayload> {
268 Box::new(self.clone())
269 }
270 fn as_any(&self) -> &dyn std::any::Any {
271 self
272 }
273 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
274 self
275 }
276 }
277 };
278 // `audit_serialize` opts a `Serialize` payload into content-provenance
279 // hashing. `canonical_audit_bytes` sorts object keys, so the bytes are
280 // stable across processes even when the payload holds a `HashMap`.
281 // Without that, two identical payloads could hash differently and a
282 // consumer comparing digests would read them as different content.
283 ($ty:ty, audit_serialize) => {
284 impl $crate::hooks::payload::PluginPayload for $ty {
285 fn clone_boxed(&self) -> Box<dyn $crate::hooks::payload::PluginPayload> {
286 Box::new(self.clone())
287 }
288 fn as_any(&self) -> &dyn std::any::Any {
289 self
290 }
291 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
292 self
293 }
294 fn audit_bytes(&self) -> Option<Vec<u8>> {
295 $crate::hooks::payload::canonical_audit_bytes(self)
296 }
297 }
298 };
299}
300
301#[cfg(test)]
302#[expect(
303 clippy::expect_used,
304 clippy::indexing_slicing,
305 clippy::unwrap_used,
306 reason = "tests"
307)]
308mod tests {
309 use super::*;
310
311 #[derive(Debug, Clone, serde::Serialize)]
312 struct Hashable {
313 keys: std::collections::HashMap<String, u32>,
314 name: String,
315 }
316 crate::impl_plugin_payload!(Hashable, audit_serialize);
317
318 #[derive(Debug, Clone)]
319 struct Plain;
320 crate::impl_plugin_payload!(Plain);
321
322 fn unkeyed(bytes: &[u8]) -> String {
323 ContentKey::Unkeyed.digest(bytes).unwrap()
324 }
325
326 fn keyed(secret: &str) -> ContentKey {
327 ContentKey::keyed(crate::secrets::SecretRef::fixed(secret)).unwrap()
328 }
329
330 const KEY_A: &str = "a-provenance-key-of-thirty-two-bytes-or-more";
331 const KEY_B: &str = "another-provenance-key-of-thirty-two-bytes";
332
333 #[test]
334 fn an_unkeyed_digest_is_prefixed_deterministic_and_content_dependent() {
335 let h = unkeyed(b"hello");
336
337 assert!(h.starts_with("sha256:"));
338 assert_eq!(h.len(), "sha256:".len() + 64);
339 assert_eq!(unkeyed(b"hello"), h, "the same bytes hash the same");
340 assert_ne!(unkeyed(b"world"), h);
341 }
342
343 #[test]
344 fn a_keyed_digest_names_its_scheme_and_key() {
345 let h = keyed(KEY_A).digest(b"hello").unwrap();
346 let parts: Vec<&str> = h.split(':').collect();
347
348 assert_eq!(parts.len(), 3, "scheme, key id, digest: {h}");
349 assert_eq!(parts[0], "hmac-sha256");
350 assert_eq!(parts[1].len(), 16);
351 assert_eq!(parts[2].len(), 64);
352 }
353
354 /// Equal content under one key gives equal digests, which is what lets a
355 /// reader spot a payload replayed across requests.
356 #[test]
357 fn the_same_key_and_content_digest_the_same() {
358 assert_eq!(keyed(KEY_A).digest(b"hello"), keyed(KEY_A).digest(b"hello"));
359 assert_ne!(keyed(KEY_A).digest(b"hello"), keyed(KEY_A).digest(b"world"));
360 }
361
362 /// A rotated key shows up as a different key id, so a reader does not
363 /// compare digests across it and read the rotation as the content changing.
364 #[test]
365 fn a_different_key_has_a_different_id() {
366 let id = |key: &str| {
367 keyed(key)
368 .digest(b"hello")
369 .unwrap()
370 .split(':')
371 .nth(1)
372 .unwrap()
373 .to_owned()
374 };
375 assert_eq!(id(KEY_A), id(KEY_A), "the id is stable for one key");
376 assert_ne!(id(KEY_A), id(KEY_B));
377 }
378
379 /// The keyed digest is not the plain hash of the content, which is the
380 /// whole point: a guess cannot be checked without the key.
381 #[test]
382 fn a_keyed_digest_is_not_the_plain_hash() {
383 let plain = unkeyed(b"hello");
384 let keyed = keyed(KEY_A).digest(b"hello").unwrap();
385 assert!(!keyed.ends_with(plain.trim_start_matches("sha256:")));
386 }
387
388 #[test]
389 fn a_short_key_is_refused() {
390 let err = ContentKey::keyed(crate::secrets::SecretRef::fixed("too-short")).unwrap_err();
391 assert!(err.contains("at least 32"), "{err}");
392 }
393
394 /// A payload that did not opt in produces no bytes, so provenance costs it
395 /// nothing even when an operator turns hashing on.
396 #[test]
397 fn a_payload_that_did_not_opt_in_has_no_audit_bytes() {
398 assert!(Plain.audit_bytes().is_none());
399 }
400
401 /// The reason `audit_serialize` round-trips through `Value`: a `HashMap`
402 /// iterates in an arbitrary order, so serializing the payload directly
403 /// would hash the same content differently from run to run and a consumer
404 /// comparing digests would see changes that never happened.
405 #[test]
406 fn the_same_content_hashes_the_same_despite_map_ordering() {
407 let mut a = std::collections::HashMap::new();
408 a.insert("zebra".to_owned(), 1);
409 a.insert("apple".to_owned(), 2);
410 a.insert("mango".to_owned(), 3);
411 let mut b = std::collections::HashMap::new();
412 b.insert("mango".to_owned(), 3);
413 b.insert("apple".to_owned(), 2);
414 b.insert("zebra".to_owned(), 1);
415
416 let first = Hashable {
417 keys: a,
418 name: "x".to_owned(),
419 };
420 let second = Hashable {
421 keys: b,
422 name: "x".to_owned(),
423 };
424
425 let fh = unkeyed(&first.audit_bytes().expect("opted in"));
426 let sh = unkeyed(&second.audit_bytes().expect("opted in"));
427 assert_eq!(fh, sh, "insertion order must not change the digest");
428 }
429
430 /// Sorting has to reach nested objects too. A `HashMap` one level down
431 /// would otherwise reintroduce exactly the instability the top-level sort
432 /// removes.
433 #[test]
434 fn nested_objects_are_sorted_too() {
435 let a = serde_json::json!({ "outer": { "z": 1, "a": 2 } });
436 let b = serde_json::json!({ "outer": { "a": 2, "z": 1 } });
437
438 assert_eq!(
439 canonical_audit_bytes(&a).unwrap(),
440 canonical_audit_bytes(&b).unwrap()
441 );
442 }
443
444 /// Arrays are ordered data, so their order is content and must survive
445 /// canonicalization.
446 #[test]
447 fn array_order_is_content_and_is_preserved() {
448 let a = serde_json::json!({ "items": [1, 2, 3] });
449 let b = serde_json::json!({ "items": [3, 2, 1] });
450
451 assert_ne!(
452 canonical_audit_bytes(&a).unwrap(),
453 canonical_audit_bytes(&b).unwrap(),
454 "reordering an array changes the content"
455 );
456 }
457
458 #[test]
459 fn different_content_hashes_differently() {
460 let one = Hashable {
461 keys: std::collections::HashMap::new(),
462 name: "one".to_owned(),
463 };
464 let two = Hashable {
465 keys: std::collections::HashMap::new(),
466 name: "two".to_owned(),
467 };
468
469 assert_ne!(
470 unkeyed(&one.audit_bytes().unwrap()),
471 unkeyed(&two.audit_bytes().unwrap())
472 );
473 }
474}