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}