1use std::collections::HashMap;
13
14use tracing::warn;
15
16use camel_api::CamelError;
17use camel_api::security_policy::{AccessMode, RouteSecurityPlan};
18
19#[derive(Debug, Default, Clone)]
23pub struct BindExposureAcks(HashMap<String, bool>);
24
25impl BindExposureAcks {
26 pub fn new(map: HashMap<String, bool>) -> Self {
27 Self(map)
28 }
29
30 pub fn acknowledged(&self, bind: &str) -> bool {
33 self.0.get(bind).copied().unwrap_or(false)
34 }
35}
36
37pub fn enforce_bind_exposure_gate(
48 bind_key: &str,
49 is_loopback: bool,
50 plans: &[(&str, &RouteSecurityPlan)],
51 acked: bool,
52) -> Result<(), CamelError> {
53 if is_loopback {
54 return Ok(());
55 }
56 let public_routes: Vec<&str> = plans
57 .iter()
58 .filter(|(_, plan)| matches!(plan.access_mode, AccessMode::Public))
59 .map(|(route_id, _)| *route_id)
60 .collect();
61 if public_routes.is_empty() {
62 return Ok(());
63 }
64 if acked {
65 warn!(
66 bind = %bind_key,
67 public_routes = public_routes.len(),
68 "public (unauthenticated) routes exposed on non-loopback bind per operator acknowledgement"
69 );
70 Ok(())
71 } else {
72 Err(CamelError::RouteError(format!(
73 "bind {bind_key} exposes {} Public route(s) [{}] on a non-loopback address; \
74 acknowledge via [binds.\"{bind_key}\"] allow_public_exposure = true",
75 public_routes.len(),
76 public_routes.join(", ")
77 )))
78 }
79}
80
81#[cfg(test)]
82mod tests {
83 use super::*;
84 use camel_api::security_policy::{CredentialSource, TransportId};
85
86 fn public_plan() -> RouteSecurityPlan {
87 RouteSecurityPlan {
88 access_mode: AccessMode::Public,
89 provider_ref: None,
90 transport: TransportId::Mcp,
91 credential_sources: vec![],
92 audience_binding: None,
93 }
94 }
95
96 fn authenticated_plan() -> RouteSecurityPlan {
97 RouteSecurityPlan {
98 access_mode: AccessMode::Authenticated,
99 provider_ref: Some("idp-a".to_string()),
100 transport: TransportId::Mcp,
101 credential_sources: vec![CredentialSource::AuthorizationHeader],
102 audience_binding: None,
103 }
104 }
105
106 #[test]
107 fn gate_refuses_public_without_ack() {
108 let err =
109 enforce_bind_exposure_gate("0.0.0.0:8080", false, &[("r1", &public_plan())], false)
110 .unwrap_err();
111 match err {
112 CamelError::RouteError(msg) => {
113 assert!(
114 msg.contains("0.0.0.0:8080"),
115 "error must name the bind: {msg}"
116 );
117 assert!(msg.contains("r1"), "error must name the route: {msg}");
118 }
119 other => panic!("expected RouteError, got {other}"),
120 }
121 }
122
123 #[test]
124 fn gate_passes_when_acked() {
125 assert!(
126 enforce_bind_exposure_gate("0.0.0.0:8080", false, &[("r1", &public_plan())], true)
127 .is_ok()
128 );
129 }
130
131 #[test]
132 fn gate_passes_loopback_and_non_public_without_ack() {
133 assert!(
134 enforce_bind_exposure_gate("127.0.0.1:8080", true, &[("r1", &public_plan())], false)
135 .is_ok()
136 );
137 assert!(
138 enforce_bind_exposure_gate(
139 "10.0.0.1:9000",
140 false,
141 &[("r1", &authenticated_plan())],
142 false
143 )
144 .is_ok()
145 );
146 }
147}