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