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 binding_allowed: HashSet<String>,
81 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 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 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 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 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 assert!(!policy.is_allowed("llm-api-key"));
523
524 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}