1use rmcp::{ErrorData as McpError, model::*};
2
3use crate::{
4 control_plane::{config::AppConfig, policy::Policy},
5 mcp::{helpers::run_json, params::SyncParams},
6};
7
8pub async fn handle_sync(
9 config: &AppConfig,
10 from_policy: &Policy,
11 to_policy: &Policy,
12 p: SyncParams,
13) -> Result<CallToolResult, McpError> {
14 let profile_zones = p
15 .profile
16 .as_deref()
17 .and_then(|name| {
18 config
19 .sync
20 .iter()
21 .find(|profile| profile.name.eq_ignore_ascii_case(name))
22 })
23 .map(|profile| profile.zones.as_slice())
24 .unwrap_or(&[]);
25 let effective_zones = if p.zones.is_empty() {
26 profile_zones
27 } else {
28 p.zones.as_slice()
29 };
30 let zone_check = if effective_zones.is_empty()
31 && (from_policy.allowed_zones.is_some() || to_policy.allowed_zones.is_some())
32 {
33 Err(crate::core::error::Error::policy_violation(
34 "MCP sync with zone allowlists requires explicit zones",
35 "Pass `zones` in the tool call or configure zones on the selected sync profile.",
36 ))
37 } else {
38 effective_zones
39 .iter()
40 .try_for_each(|zone| from_policy.check_zone(zone).and(to_policy.check_zone(zone)))
41 };
42 let check = from_policy
43 .check_read()
44 .and(to_policy.check_write())
45 .and(zone_check);
46
47 Ok(run_json("dns_sync", check, async move {
48 crate::control_plane::sync::run_sync_json(
49 Some(config),
50 p.profile.as_deref(),
51 p.from.as_deref(),
52 p.to.as_deref(),
53 &p.zones,
54 &p.map,
55 p.apply,
56 )
57 .await
58 })
59 .await)
60}
61
62#[cfg(test)]
63mod tests {
64 use serde_json::Value;
65
66 use super::*;
67 use crate::control_plane::policy::PolicyRule;
68
69 #[tokio::test]
70 async fn restricted_sync_requires_explicit_zones() {
71 let config = AppConfig::default();
72 let from_policy = Policy::new([PolicyRule::Read], Some(vec!["example.com".to_string()]));
73 let to_policy = Policy::new([PolicyRule::Write], None);
74
75 let result = handle_sync(
76 &config,
77 &from_policy,
78 &to_policy,
79 SyncParams {
80 profile: None,
81 from: Some("from".to_string()),
82 to: Some("to".to_string()),
83 zones: Vec::new(),
84 map: Vec::new(),
85 apply: false,
86 },
87 )
88 .await
89 .unwrap();
90
91 assert_eq!(result.is_error, Some(true));
92 let text = result.content[0]
93 .as_text()
94 .expect("policy denial should be returned as text JSON");
95 let value: Value = serde_json::from_str(&text.text).unwrap();
96 assert!(
97 value["error"]
98 .as_str()
99 .unwrap()
100 .contains("requires explicit zones")
101 );
102 }
103}