Skip to main content

camel_auth/
bind_gate.rs

1//! Per-bind public-exposure gate (ADR-0061 Rule 4).
2//!
3//! Canonical home: `camel-auth`. The gate was born in `camel-core`
4//! (`route_controller_trait`, Task 1.9) and moved here in Task 2.6 so the
5//! MCP registry can enforce it too: components may not reference
6//! `camel_core::` (hexagonal invariant, `xtask lint-component-deps`), while
7//! both `camel-core` and the transports already depend on `camel-auth`.
8//! `camel-core` re-exports both items from
9//! [`crate::lifecycle::adapters::route_controller_trait`], so existing
10//! callers (controller, CLI) keep their import paths.
11
12use std::collections::HashMap;
13
14use tracing::warn;
15
16use camel_api::CamelError;
17use camel_api::security_policy::{AccessMode, RouteSecurityPlan};
18
19/// Operator acknowledgements for public exposure per bind address
20/// (ADR-0061). Plain map — camel-core stays camel-config-free; the CLI
21/// builds this from `CamelConfig.binds` and passes it into route staging.
22#[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    /// Whether the operator acknowledged public exposure for `bind`
31    /// (bind address string, e.g. `"0.0.0.0:8080"`). Absent → false.
32    pub fn acknowledged(&self, bind: &str) -> bool {
33        self.0.get(bind).copied().unwrap_or(false)
34    }
35}
36
37/// Per-bind exposure gate (ADR-0061): a bind exposing `Public` routes on a
38/// non-loopback address refuses to start unless the operator acknowledged
39/// it. Loopback binds pass without acknowledgement. An acknowledged bind
40/// still emits a permanent warning naming the bind and route count —
41/// acknowledgement never silences the warning (ADR-0052 rule 3).
42///
43/// `bind_key` is the canonical key `[binds."<addr>"]` acks use (IP-literal
44/// authority or hostname authority as written). Hostname authorities are
45/// treated as non-loopback unless the host is `localhost` — no DNS lookup,
46/// the decision stays deterministic and fail-closed.
47pub 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}