Skip to main content

greentic_runner_host/
config.rs

1use crate::gtbind::PackBinding;
2use crate::gtbind::TenantBindings;
3use crate::oauth::OAuthBrokerConfig;
4use crate::runner::mocks::MocksConfig;
5use crate::trace::TraceConfig;
6use crate::validate::ValidationConfig;
7use anyhow::{Context, Result};
8use parking_lot::RwLock;
9use serde::Deserialize;
10use serde_json::Value;
11use serde_yaml_bw as serde_yaml;
12use std::collections::{HashMap, HashSet};
13use std::fs;
14use std::path::{Path, PathBuf};
15use std::str::FromStr;
16use std::sync::Arc;
17
18#[derive(Debug, Clone)]
19pub struct HostConfig {
20    pub tenant: String,
21    pub bindings_path: PathBuf,
22    pub flow_type_bindings: HashMap<String, FlowBinding>,
23    pub rate_limits: RateLimits,
24    pub retry: FlowRetryConfig,
25    pub http_enabled: bool,
26    pub secrets_policy: SecretsPolicy,
27    pub state_store_policy: StateStorePolicy,
28    pub webhook_policy: WebhookPolicy,
29    pub timers: Vec<TimerBinding>,
30    pub oauth: Option<OAuthConfig>,
31    pub mocks: Option<MocksConfig>,
32    pub pack_bindings: Vec<PackBinding>,
33    pub env_passthrough: Vec<String>,
34    pub trace: TraceConfig,
35    pub validation: ValidationConfig,
36    pub operator_policy: OperatorPolicy,
37}
38
39#[derive(Debug, Clone, Deserialize)]
40pub struct BindingsFile {
41    pub tenant: String,
42    #[serde(default)]
43    pub flow_type_bindings: HashMap<String, FlowBinding>,
44    #[serde(default)]
45    pub rate_limits: RateLimits,
46    #[serde(default)]
47    pub retry: FlowRetryConfig,
48    #[serde(default)]
49    pub timers: Vec<TimerBinding>,
50    #[serde(default)]
51    pub oauth: Option<OAuthConfig>,
52    #[serde(default)]
53    pub mocks: Option<MocksConfig>,
54    #[serde(default)]
55    pub state_store: StateStorePolicy,
56    #[serde(default)]
57    pub operator: OperatorPolicyConfig,
58}
59
60#[derive(Debug, Clone, Deserialize)]
61pub struct FlowBinding {
62    pub adapter: String,
63    #[serde(default)]
64    pub config: serde_yaml::Value,
65    #[serde(default)]
66    pub secrets: Vec<String>,
67}
68
69#[derive(Debug, Clone, Deserialize)]
70pub struct RateLimits {
71    #[serde(default = "default_messaging_qps")]
72    pub messaging_send_qps: u32,
73    #[serde(default = "default_messaging_burst")]
74    pub messaging_burst: u32,
75}
76
77#[derive(Debug, Clone)]
78pub struct SecretsPolicy {
79    /// Secret names allowed by the bindings file.
80    binding_allowed: HashSet<String>,
81    /// Secret names discovered at flow-load time from node configs that
82    /// reference secrets via fields ending in `_secret` (e.g.
83    /// `api_key_secret: "llm-api-key"`). Shared across all clones so that
84    /// flow loading can register names after policy construction.
85    flow_discovered: Arc<RwLock<HashSet<String>>>,
86    allow_all: bool,
87}
88
89#[derive(Debug, Clone, Deserialize, Default)]
90pub struct OperatorPolicyConfig {
91    #[serde(default)]
92    pub allowed_providers: Vec<String>,
93    #[serde(default)]
94    pub allowed_ops: HashMap<String, Vec<String>>,
95}
96
97#[derive(Debug, Clone)]
98pub struct OperatorPolicy {
99    allow_all: bool,
100    allowed_providers: HashSet<String>,
101    allowed_ops: HashMap<String, HashSet<String>>,
102}
103
104#[derive(Debug, Clone, Deserialize)]
105pub struct FlowRetryConfig {
106    #[serde(default = "default_retry_attempts")]
107    pub max_attempts: u32,
108    #[serde(default = "default_retry_base_delay_ms")]
109    pub base_delay_ms: u64,
110}
111
112#[derive(Debug, Clone, Default)]
113pub struct WebhookPolicy {
114    allow_paths: Vec<String>,
115    deny_paths: Vec<String>,
116}
117
118#[derive(Debug, Clone, Deserialize)]
119pub struct StateStorePolicy {
120    #[serde(default = "default_state_store_allow")]
121    pub allow: bool,
122}
123
124#[derive(Debug, Clone, Deserialize)]
125pub struct WebhookBindingConfig {
126    #[serde(default)]
127    pub allow_paths: Vec<String>,
128    #[serde(default)]
129    pub deny_paths: Vec<String>,
130}
131
132#[derive(Debug, Clone, Deserialize)]
133pub struct TimerBinding {
134    pub flow_id: String,
135    pub cron: String,
136    #[serde(default)]
137    pub schedule_id: Option<String>,
138}
139
140#[derive(Debug, Clone, Deserialize)]
141pub struct OAuthConfig {
142    pub http_base_url: String,
143    pub nats_url: String,
144    pub provider: String,
145    #[serde(default)]
146    pub env: Option<String>,
147    #[serde(default)]
148    pub team: Option<String>,
149}
150
151impl HostConfig {
152    pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self> {
153        let path = path.as_ref();
154        let content = fs::read_to_string(path)
155            .with_context(|| format!("failed to read bindings file {path:?}"))?;
156        let bindings: BindingsFile = serde_yaml::from_str(&content)
157            .with_context(|| format!("failed to parse bindings file {path:?}"))?;
158
159        let secrets_policy = SecretsPolicy::from_bindings(&bindings);
160        let http_enabled = bindings.flow_type_bindings.contains_key("messaging");
161        let webhook_policy = bindings
162            .flow_type_bindings
163            .get("webhook")
164            .and_then(|binding| {
165                serde_yaml::from_value::<WebhookBindingConfig>(binding.config.clone())
166                    .map(WebhookPolicy::from)
167                    .map_err(|err| {
168                        tracing::warn!(error = %err, "failed to parse webhook binding config");
169                        err
170                    })
171                    .ok()
172            })
173            .unwrap_or_default();
174
175        Ok(Self {
176            tenant: bindings.tenant.clone(),
177            bindings_path: path.to_path_buf(),
178            flow_type_bindings: bindings.flow_type_bindings.clone(),
179            rate_limits: bindings.rate_limits.clone(),
180            retry: bindings.retry.clone(),
181            http_enabled,
182            secrets_policy,
183            state_store_policy: bindings.state_store.clone(),
184            webhook_policy,
185            timers: bindings.timers.clone(),
186            oauth: bindings.oauth.clone(),
187            mocks: bindings.mocks.clone(),
188            pack_bindings: Vec::new(),
189            env_passthrough: Vec::new(),
190            trace: TraceConfig::from_env(),
191            validation: ValidationConfig::from_env(),
192            operator_policy: OperatorPolicy::from_config(bindings.operator.clone()),
193        })
194    }
195
196    pub fn from_gtbind(bindings: TenantBindings) -> Self {
197        Self {
198            tenant: bindings.tenant,
199            bindings_path: PathBuf::from("<gtbind>"),
200            flow_type_bindings: HashMap::new(),
201            rate_limits: RateLimits::default(),
202            retry: FlowRetryConfig::default(),
203            // GTBind-backed embedded hosts do not populate flow_type_bindings,
204            // so the YAML-path heuristic cannot be used here. Keep outbound
205            // host HTTP enabled so components like component-llm-openai can
206            // reach their configured providers.
207            http_enabled: true,
208            secrets_policy: SecretsPolicy::allow_all(),
209            state_store_policy: StateStorePolicy::default(),
210            webhook_policy: WebhookPolicy::default(),
211            timers: Vec::new(),
212            oauth: None,
213            mocks: None,
214            pack_bindings: bindings.packs,
215            env_passthrough: bindings.env_passthrough,
216            trace: TraceConfig::from_env(),
217            validation: ValidationConfig::from_env(),
218            operator_policy: OperatorPolicy::allow_all(),
219        }
220    }
221
222    pub fn messaging_binding(&self) -> Option<&FlowBinding> {
223        self.flow_type_bindings.get("messaging")
224    }
225
226    pub fn retry_config(&self) -> FlowRetryConfig {
227        self.retry.clone()
228    }
229
230    pub fn oauth_broker_config(&self) -> Option<OAuthBrokerConfig> {
231        let oauth = self.oauth.as_ref()?;
232        let mut cfg = OAuthBrokerConfig::new(&oauth.http_base_url, &oauth.nats_url);
233        if !oauth.provider.is_empty() {
234            cfg.default_provider = Some(oauth.provider.clone());
235        }
236        if let Some(team) = &oauth.team
237            && !team.is_empty()
238        {
239            cfg.team = Some(team.clone());
240        }
241        Some(cfg)
242    }
243
244    /// Derive a tenant context for the current configuration. This is used when
245    /// evaluating scoped requirements (e.g. secrets).
246    pub fn tenant_ctx(&self) -> greentic_types::TenantCtx {
247        let env = std::env::var("GREENTIC_ENV").unwrap_or_else(|_| "local".to_string());
248        let env_id = greentic_types::EnvId::from_str(&env)
249            .unwrap_or_else(|_| greentic_types::EnvId::new("local").expect("local env id"));
250        let tenant_id = greentic_types::TenantId::from_str(&self.tenant)
251            .unwrap_or_else(|_| greentic_types::TenantId::new("local").expect("tenant id"));
252        greentic_types::TenantCtx::new(env_id, tenant_id)
253    }
254}
255
256impl SecretsPolicy {
257    fn from_bindings(bindings: &BindingsFile) -> Self {
258        let binding_allowed = bindings
259            .flow_type_bindings
260            .values()
261            .flat_map(|binding| binding.secrets.iter().cloned())
262            .collect::<HashSet<_>>();
263        Self {
264            binding_allowed,
265            flow_discovered: Arc::new(RwLock::new(HashSet::new())),
266            allow_all: false,
267        }
268    }
269
270    pub fn is_allowed(&self, key: &str) -> bool {
271        if self.allow_all || self.binding_allowed.contains(key) {
272            return true;
273        }
274        self.flow_discovered.read().contains(key)
275    }
276
277    pub fn allow_all() -> Self {
278        Self {
279            binding_allowed: HashSet::new(),
280            flow_discovered: Arc::new(RwLock::new(HashSet::new())),
281            allow_all: true,
282        }
283    }
284
285    /// Register a secret name discovered while loading a flow's node config.
286    ///
287    /// Components are gated by [`is_allowed`]; the bindings file is the
288    /// authoritative source, but flows that reference secrets directly via
289    /// node config fields (`api_key_secret: "llm-api-key"`) would otherwise
290    /// have their lookups denied even when the secret is provisioned. Calling
291    /// this from the pack flow loader closes that gap without changing the
292    /// component manifest contract.
293    pub fn register_flow_secret(&self, name: &str) {
294        if name.is_empty() {
295            return;
296        }
297        self.flow_discovered.write().insert(name.to_string());
298    }
299
300    /// Walk a JSON value (typically a flow node's config block) and register
301    /// any string-valued field whose key ends in `_secret`. Recurses through
302    /// nested objects and arrays.
303    pub fn register_flow_secret_refs(&self, value: &Value) {
304        match value {
305            Value::Object(map) => {
306                for (key, val) in map {
307                    if key.ends_with("_secret")
308                        && let Value::String(name) = val
309                    {
310                        self.register_flow_secret(name);
311                    } else {
312                        self.register_flow_secret_refs(val);
313                    }
314                }
315            }
316            Value::Array(items) => {
317                for item in items {
318                    self.register_flow_secret_refs(item);
319                }
320            }
321            _ => {}
322        }
323    }
324}
325
326impl OperatorPolicy {
327    pub fn from_config(config: OperatorPolicyConfig) -> Self {
328        let allowed_providers = config.allowed_providers.into_iter().collect::<HashSet<_>>();
329        let allowed_ops = config
330            .allowed_ops
331            .into_iter()
332            .map(|(provider, ops)| (provider, ops.into_iter().collect::<HashSet<_>>()))
333            .collect::<HashMap<_, _>>();
334        let allow_all = allowed_providers.is_empty() && allowed_ops.is_empty();
335        Self {
336            allow_all,
337            allowed_providers,
338            allowed_ops,
339        }
340    }
341
342    pub fn allow_all() -> Self {
343        Self {
344            allow_all: true,
345            allowed_providers: HashSet::new(),
346            allowed_ops: HashMap::new(),
347        }
348    }
349
350    pub fn allows_provider(&self, provider_id: Option<&str>, provider_type: &str) -> bool {
351        if self.allow_all {
352            return true;
353        }
354        provider_id
355            .map(|id| self.allowed_providers.contains(id))
356            .unwrap_or(false)
357            || self.allowed_providers.contains(provider_type)
358    }
359
360    pub fn allows_op(&self, provider_id: Option<&str>, provider_type: &str, op_id: &str) -> bool {
361        if self.allow_all {
362            return true;
363        }
364        if let Some(ops) = provider_id.and_then(|id| self.allowed_ops.get(id)) {
365            return ops.contains(op_id);
366        }
367        if let Some(ops) = self.allowed_ops.get(provider_type) {
368            return ops.contains(op_id);
369        }
370        self.allows_provider(provider_id, provider_type)
371    }
372}
373
374impl Default for RateLimits {
375    fn default() -> Self {
376        Self {
377            messaging_send_qps: default_messaging_qps(),
378            messaging_burst: default_messaging_burst(),
379        }
380    }
381}
382
383impl Default for StateStorePolicy {
384    fn default() -> Self {
385        Self {
386            allow: default_state_store_allow(),
387        }
388    }
389}
390
391fn default_messaging_qps() -> u32 {
392    10
393}
394
395fn default_messaging_burst() -> u32 {
396    20
397}
398
399fn default_state_store_allow() -> bool {
400    true
401}
402
403impl From<WebhookBindingConfig> for WebhookPolicy {
404    fn from(value: WebhookBindingConfig) -> Self {
405        Self {
406            allow_paths: value.allow_paths,
407            deny_paths: value.deny_paths,
408        }
409    }
410}
411
412impl WebhookPolicy {
413    pub fn is_allowed(&self, path: &str) -> bool {
414        if self
415            .deny_paths
416            .iter()
417            .any(|prefix| path.starts_with(prefix))
418        {
419            return false;
420        }
421
422        if self.allow_paths.is_empty() {
423            return true;
424        }
425
426        self.allow_paths
427            .iter()
428            .any(|prefix| path.starts_with(prefix))
429    }
430}
431
432impl TimerBinding {
433    pub fn schedule_id(&self) -> &str {
434        self.schedule_id.as_deref().unwrap_or(self.flow_id.as_str())
435    }
436}
437
438impl Default for FlowRetryConfig {
439    fn default() -> Self {
440        Self {
441            max_attempts: default_retry_attempts(),
442            base_delay_ms: default_retry_base_delay_ms(),
443        }
444    }
445}
446
447#[cfg(test)]
448mod operator_policy_tests {
449    use super::{OperatorPolicy, OperatorPolicyConfig};
450    use std::collections::HashMap;
451
452    #[test]
453    fn policy_allows_configured_provider_op() {
454        let mut allowed_ops = HashMap::new();
455        allowed_ops.insert("provider.allowed".into(), vec!["op1".into(), "op2".into()]);
456        let config = OperatorPolicyConfig {
457            allowed_providers: vec!["provider.allowed".into()],
458            allowed_ops,
459        };
460        let policy = OperatorPolicy::from_config(config);
461        assert!(policy.allows_provider(Some("provider.allowed"), "provider.allowed"));
462        assert!(policy.allows_op(Some("provider.allowed"), "provider.allowed", "op1"));
463        assert!(!policy.allows_op(Some("provider.allowed"), "provider.allowed", "other"));
464        assert!(!policy.allows_provider(Some("provider.denied"), "provider.denied"));
465    }
466
467    #[test]
468    fn policy_allow_all_defaults_true() {
469        let policy = OperatorPolicy::allow_all();
470        assert!(policy.allows_provider(None, "any"));
471        assert!(policy.allows_op(None, "any", "op"));
472    }
473}
474
475fn default_retry_attempts() -> u32 {
476    3
477}
478
479fn default_retry_base_delay_ms() -> u64 {
480    250
481}
482
483#[cfg(test)]
484#[allow(clippy::items_after_test_module)]
485mod tests {
486    use super::*;
487    use crate::gtbind::{PackBinding, TenantBindings};
488    use std::collections::HashMap;
489    use std::path::PathBuf;
490
491    fn host_config_with_oauth(oauth: Option<OAuthConfig>) -> HostConfig {
492        HostConfig {
493            tenant: "tenant-a".to_string(),
494            bindings_path: PathBuf::from("/tmp/bindings.yaml"),
495            flow_type_bindings: HashMap::new(),
496            rate_limits: RateLimits::default(),
497            retry: FlowRetryConfig::default(),
498            http_enabled: false,
499            secrets_policy: SecretsPolicy::allow_all(),
500            state_store_policy: StateStorePolicy::default(),
501            webhook_policy: WebhookPolicy::default(),
502            timers: Vec::new(),
503            oauth,
504            mocks: None,
505            pack_bindings: Vec::new(),
506            env_passthrough: Vec::new(),
507            trace: TraceConfig::from_env(),
508            validation: ValidationConfig::from_env(),
509            operator_policy: OperatorPolicy::allow_all(),
510        }
511    }
512
513    #[test]
514    fn secrets_policy_register_flow_secret_refs_walks_nested_objects() {
515        let policy = SecretsPolicy {
516            binding_allowed: HashSet::new(),
517            flow_discovered: Arc::new(RwLock::new(HashSet::new())),
518            allow_all: false,
519        };
520
521        // Bare bindings deny anything by default.
522        assert!(!policy.is_allowed("llm-api-key"));
523
524        // Simulate a flow node config block carrying both a top-level
525        // *_secret reference and a nested one inside an arbitrary subtree.
526        let node_config = serde_json::json!({
527            "api_key_secret": "llm-api-key",
528            "provider": "openai",
529            "fallback": {
530                "secondary_api_key_secret": "openrouter-key",
531                "model": "gpt-4o",
532            },
533            "list": [
534                { "tertiary_secret": "another-key" },
535                { "non_secret_field": "ignored" },
536            ],
537            "ignored_field": ""
538        });
539
540        policy.register_flow_secret_refs(&node_config);
541
542        assert!(policy.is_allowed("llm-api-key"));
543        assert!(policy.is_allowed("openrouter-key"));
544        assert!(policy.is_allowed("another-key"));
545        assert!(!policy.is_allowed("non_secret_field"));
546        assert!(!policy.is_allowed("ignored"));
547    }
548
549    #[test]
550    fn secrets_policy_ignores_empty_or_non_string_secret_values() {
551        let policy = SecretsPolicy {
552            binding_allowed: HashSet::new(),
553            flow_discovered: Arc::new(RwLock::new(HashSet::new())),
554            allow_all: false,
555        };
556
557        let node_config = serde_json::json!({
558            "api_key_secret": "",
559            "fallback_secret": null,
560            "numeric_secret": 42,
561            "real_secret": "good-key",
562        });
563
564        policy.register_flow_secret_refs(&node_config);
565
566        assert!(policy.is_allowed("good-key"));
567        assert!(!policy.is_allowed(""));
568    }
569
570    #[test]
571    fn oauth_broker_config_absent_without_block() {
572        let cfg = host_config_with_oauth(None);
573        assert!(cfg.oauth_broker_config().is_none());
574    }
575
576    #[test]
577    fn oauth_broker_config_maps_fields() {
578        let cfg = host_config_with_oauth(Some(OAuthConfig {
579            http_base_url: "https://oauth.example/".into(),
580            nats_url: "nats://broker:4222".into(),
581            provider: "demo".into(),
582            env: None,
583            team: Some("ops".into()),
584        }));
585        let broker = cfg.oauth_broker_config().expect("missing broker config");
586        assert_eq!(broker.http_base_url, "https://oauth.example/");
587        assert_eq!(broker.nats_url, "nats://broker:4222");
588        assert_eq!(broker.default_provider.as_deref(), Some("demo"));
589        assert_eq!(broker.team.as_deref(), Some("ops"));
590    }
591
592    #[test]
593    fn gtbind_configs_enable_outbound_http() {
594        let cfg = HostConfig::from_gtbind(TenantBindings {
595            tenant: "demo".into(),
596            packs: vec![PackBinding {
597                pack_id: "deep-research-demo".into(),
598                pack_ref: "deep-research-demo@0.1.0".into(),
599                pack_locator: None,
600                flows: vec!["main".into()],
601            }],
602            env_passthrough: Vec::new(),
603        });
604
605        assert!(
606            cfg.http_enabled,
607            "gtbind-backed tenants should allow outbound component HTTP"
608        );
609    }
610}