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` (camel-core internal
10//! path — camel-auth cannot intra-doc-link camel-core: the dependency
11//! direction is camel-core → camel-auth), so existing
12//! callers (controller, CLI) keep their import paths.
13
14use std::collections::HashMap;
15
16use tracing::warn;
17
18use camel_api::CamelError;
19use camel_api::security_policy::{AccessMode, RouteSecurityPlan};
20
21/// Operator acknowledgements for public exposure per bind address
22/// (ADR-0061). Plain map — camel-core stays camel-config-free; the CLI
23/// builds this from `CamelConfig.binds` and passes it into route staging.
24#[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    /// Whether the operator acknowledged public exposure for `bind`
33    /// (bind address string, e.g. `"0.0.0.0:8080"`). Absent → false.
34    pub fn acknowledged(&self, bind: &str) -> bool {
35        self.0.get(bind).copied().unwrap_or(false)
36    }
37}
38
39/// Per-bind exposure gate (ADR-0061): a bind exposing `Public` routes on a
40/// non-loopback address refuses to start unless the operator acknowledged
41/// it. Loopback binds pass without acknowledgement. An acknowledged bind
42/// still emits a permanent warning naming the bind and route count —
43/// acknowledgement never silences the warning (ADR-0052 rule 3).
44///
45/// `bind_key` is the canonical key `[binds."<addr>"]` acks use (IP-literal
46/// authority or hostname authority as written). Hostname authorities are
47/// treated as non-loopback unless the host is `localhost` — no DNS lookup,
48/// the decision stays deterministic and fail-closed.
49pub 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}