Skip to main content

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}