Skip to main content

dataflow_rs/engine/
secrets.rs

1//! # Secrets
2//!
3//! Values a workflow may *read* but the engine must never *record*.
4//!
5//! `Message.context` is both what expressions evaluate against and what the
6//! engine serializes, snapshots into every [`crate::ExecutionTrace`] step and
7//! clones per `map` mapping. For almost every value that is right. For a
8//! signing key it is exactly wrong, and there is no way to say so from inside
9//! the context — `TraceOptions::redact_paths` prunes named subtrees after the
10//! fact, which is the tool you need when a value should not have been there.
11//!
12//! So secrets do not live in the context at all. They live in a [`Secrets`]
13//! store held by the [`crate::Engine`] and are reached through one door: the
14//! reserved JSONLogic operator `{"secret": "name"}`, registered on the
15//! engine's datalogic instance. Because the store is never part of a
16//! `Message`, a secret cannot appear in `Serialize for Message`, in a trace
17//! snapshot, in a `mapping_contexts` clone, or in anything a host derives from
18//! a message — there is nothing to exclude.
19//!
20//! The operator is registered on every engine, whether or not secrets were
21//! configured. In templating mode an unregistered name would echo back as
22//! literal data, so `{"secret": "k"}` on a plain engine would be handed to a
23//! handler as an ordinary object — say, as an `Authorization` header. Always
24//! registering makes it a loud error instead.
25
26use crate::engine::error::{DataflowError, Result};
27use crate::engine::utils::get_nested_value;
28use datalogic_rs::bumpalo::Bump;
29use datalogic_rs::operator::EvalContext;
30use datalogic_rs::{CustomOperator, DataValue, Error as LogicError};
31use datavalue::OwnedDataValue;
32use std::fmt;
33use std::sync::Arc;
34
35/// The reserved operator name. A host cannot register its own operator under
36/// it — [`crate::EngineBuilder::build`] refuses.
37pub const SECRET_OPERATOR: &str = "secret";
38
39/// An engine-scoped store of values readable through `{"secret": "name"}`.
40///
41/// Always an object; nested objects are allowed so a host can namespace
42/// (`{"secret": "partner.hmac"}`). Deliberately implements neither
43/// `Serialize` nor `Clone`, and its `Debug` prints key names with the values
44/// masked.
45pub struct Secrets {
46    root: OwnedDataValue,
47}
48
49/// [`Secrets::empty`] with a `'static` address — what a
50/// [`crate::TaskContext`] built outside the engine reads, and what
51/// `EngineBuilder::check_workflow` checks against when no store is set.
52pub(crate) static EMPTY: Secrets = Secrets::empty();
53
54impl Secrets {
55    /// A store with nothing in it. What every engine carries when the host
56    /// configured no secrets — the operator still exists, and every lookup
57    /// fails.
58    pub(crate) const fn empty() -> Self {
59        Self {
60            root: OwnedDataValue::Object(Vec::new()),
61        }
62    }
63
64    /// Wrap a host-supplied value. Must be an object.
65    pub(crate) fn new(root: OwnedDataValue) -> Result<Self> {
66        if !root.is_object() {
67            return Err(DataflowError::Validation(
68                "secrets must be a JSON object of name -> value".to_string(),
69            ));
70        }
71        Ok(Self { root })
72    }
73
74    /// Look up a dotted path. The empty path is `None` — never the whole
75    /// store.
76    pub fn get(&self, path: &str) -> Option<&OwnedDataValue> {
77        if path.is_empty() {
78            return None;
79        }
80        get_nested_value(&self.root, path)
81    }
82
83    /// Top-level key names. Never values.
84    pub fn names(&self) -> impl Iterator<Item = &str> {
85        self.root
86            .as_object()
87            .into_iter()
88            .flatten()
89            .map(|(k, _)| k.as_str())
90    }
91}
92
93impl fmt::Debug for Secrets {
94    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95        let mut s = f.debug_struct("Secrets");
96        for name in self.names() {
97            s.field(name, &"******");
98        }
99        s.finish()
100    }
101}
102
103/// The `secret` operator: one string argument, a dotted path into the store.
104///
105/// Error text names the *key* — never a value, and never the store.
106pub(crate) struct SecretOperator(pub(crate) Arc<Secrets>);
107
108impl CustomOperator for SecretOperator {
109    fn evaluate<'a>(
110        &self,
111        args: &[&'a DataValue<'a>],
112        _ctx: &mut EvalContext<'_, 'a>,
113        arena: &'a Bump,
114    ) -> datalogic_rs::Result<&'a DataValue<'a>> {
115        let key = match args {
116            [DataValue::String(s)] if !s.is_empty() => *s,
117            [DataValue::String(_)] => {
118                return Err(LogicError::invalid_arguments(
119                    "secret: the key must not be empty",
120                ));
121            }
122            [_] => {
123                return Err(LogicError::invalid_arguments(
124                    "secret: the key must be a string",
125                ));
126            }
127            _ => {
128                return Err(LogicError::invalid_arguments(format!(
129                    "secret: takes exactly one argument, got {}",
130                    args.len()
131                )));
132            }
133        };
134        match self.0.get(key) {
135            Some(value) => Ok(arena.alloc(value.to_arena(arena))),
136            None => Err(LogicError::variable_not_found(format!(
137                "secret '{key}' is not declared"
138            ))),
139        }
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use serde_json::json;
147
148    fn store() -> Secrets {
149        Secrets::new(OwnedDataValue::from(&json!({
150            "api_token": "tok-value-9f8e",
151            "partner": { "hmac": "hmac-value-1a2b" }
152        })))
153        .unwrap()
154    }
155
156    #[test]
157    fn debug_prints_names_and_masks_every_value() {
158        let rendered = format!("{:?}", store());
159        assert_eq!(
160            rendered,
161            r#"Secrets { api_token: "******", partner: "******" }"#
162        );
163    }
164
165    #[test]
166    fn the_empty_path_is_not_the_whole_store() {
167        assert!(store().get("").is_none());
168        assert_eq!(
169            store().get("partner.hmac"),
170            Some(&OwnedDataValue::from(&json!("hmac-value-1a2b")))
171        );
172        assert!(store().get("partner.nope").is_none());
173    }
174
175    #[test]
176    fn only_objects_are_accepted() {
177        assert!(Secrets::new(OwnedDataValue::from(&json!(["a"]))).is_err());
178        assert!(Secrets::new(OwnedDataValue::from(&json!("s"))).is_err());
179        assert_eq!(Secrets::empty().names().count(), 0);
180    }
181}