1use std::collections::HashSet;
20
21use clap::ValueEnum;
22use serde::{Deserialize, Serialize};
23
24use crate::cli::Cli;
25use crate::control_plane::config::{AppConfig, McpPermissions};
26use crate::core::error::{Error, Result};
27
28#[derive(
33 Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ValueEnum,
34)]
35#[serde(rename_all = "lowercase")]
36pub enum PolicyRule {
37 Read,
39 Write,
41 Delete,
43}
44
45#[derive(Debug, Clone)]
47pub struct Policy {
48 pub allowed: HashSet<PolicyRule>,
50
51 pub allowed_zones: Option<Vec<String>>,
54}
55
56impl Default for Policy {
57 fn default() -> Self {
58 Self::new([PolicyRule::Read, PolicyRule::Write, PolicyRule::Delete], None)
59 }
60}
61
62impl Policy {
63 pub fn new(
65 allowed: impl IntoIterator<Item = PolicyRule>,
66 allowed_zones: Option<Vec<String>>,
67 ) -> Self {
68 Self {
69 allowed: allowed.into_iter().collect(),
70 allowed_zones: allowed_zones
71 .map(|zones| zones.into_iter().map(|z| z.to_lowercase()).collect()),
72 }
73 }
74
75 pub fn check(&self, rule: PolicyRule) -> Result<()> {
76 if self.allowed.contains(&rule) {
77 return Ok(());
78 }
79 match rule {
80 PolicyRule::Read => {
81 tracing::warn!("read rejected: read is not in the allowed set");
82 Err(Error::policy_violation(
83 "this MCP server does not permit read operations",
84 "Update this server's MCP permissions or add 'read' to the allowed operations.",
85 ))
86 }
87 PolicyRule::Write => {
88 tracing::warn!("write rejected: write is not in the allowed set");
89 Err(Error::policy_violation(
90 "this MCP server does not permit write operations",
91 "Update this server's MCP permissions or add 'write' to the allowed operations.",
92 ))
93 }
94 PolicyRule::Delete => {
95 tracing::warn!("delete rejected: delete is not in the allowed set");
96 Err(Error::policy_violation(
97 "this MCP server does not permit delete operations",
98 "Update this server's MCP permissions or add 'delete' to the allowed operations.",
99 ))
100 }
101 }
102 }
103
104 pub fn check_read(&self) -> Result<()> {
107 self.check(PolicyRule::Read)
108 }
109
110 pub fn check_write(&self) -> Result<()> {
113 self.check(PolicyRule::Write)
114 }
115
116 pub fn check_delete(&self) -> Result<()> {
117 self.check(PolicyRule::Delete)
118 }
119
120 pub fn check_zone(&self, zone: &str) -> Result<()> {
121 let Some(allowed_zones) = &self.allowed_zones else {
122 return Ok(());
123 };
124
125 let zone = zone.trim_end_matches('.').to_lowercase();
126 let allowed = allowed_zones.iter().any(|allowed| {
127 let allowed = allowed.trim_end_matches('.').to_lowercase();
128 zone == allowed || zone.ends_with(&format!(".{allowed}"))
129 });
130
131 if allowed {
132 Ok(())
133 } else {
134 Err(Error::policy_violation(
135 format!("zone '{zone}' is outside the configured allowed zones"),
136 "Choose a zone permitted by this server's policy.",
137 ))
138 }
139 }
140
141 pub fn instructions_suffix(&self) -> String {
144 let mut parts = Vec::new();
145
146 let mut disabled: Vec<&str> = Vec::new();
148 if !self.allowed.contains(&PolicyRule::Read) {
149 disabled.push("read");
150 }
151 if !self.allowed.contains(&PolicyRule::Write) {
152 disabled.push("write");
153 }
154 if !self.allowed.contains(&PolicyRule::Delete) {
155 disabled.push("delete");
156 }
157
158 if !disabled.is_empty() {
159 let read_disabled = disabled.contains(&"read");
161 let write_disabled = disabled.contains(&"write");
162 let delete_disabled = disabled.contains(&"delete");
163
164 if read_disabled && write_disabled && !delete_disabled {
165 parts.push("⚠️ Restricted mode: read and write operations are disabled.".to_string());
167 } else if read_disabled && delete_disabled && !write_disabled {
168 parts.push(
170 "⚠️ Write-only mode: read and delete operations are disabled.".to_string(),
171 );
172 } else if write_disabled && delete_disabled && !read_disabled {
173 parts.push(
175 "⚠️ Read-only mode: all write and delete operations are disabled.".to_string(),
176 );
177 } else if read_disabled && !write_disabled && !delete_disabled {
178 parts.push(
180 "⚠️ Write mode: read operations are disabled.".to_string(),
181 );
182 } else if delete_disabled && !read_disabled && !write_disabled {
183 parts.push(
185 "⚠️ Write mode: delete operations are disabled.".to_string(),
186 );
187 } else {
188 parts.push(format!(
190 "⚠️ Restricted mode: {} operations are disabled.",
191 disabled.join(", ")
192 ));
193 }
194 }
195
196 if let Some(ref zones) = self.allowed_zones {
197 parts.push(format!(
198 "⚠️ Zone restriction: only the following zones are accessible: {}.",
199 zones.join(", ")
200 ));
201 }
202
203 if parts.is_empty() {
204 String::new()
205 } else {
206 format!("\n\n{}", parts.join("\n"))
207 }
208 }
209}
210
211impl Policy {
212 pub fn for_server(
243 server: &crate::control_plane::config::DnsServerConfig,
244 cli_access: &[PolicyRule],
245 cli_allow_zone: &[String],
246 ) -> Result<Self> {
247 let mcp = &server.mcp;
248
249 let config_set: HashSet<PolicyRule> = mcp.access.iter().cloned().collect();
250 let cli_set: HashSet<PolicyRule> = cli_access.iter().cloned().collect();
251
252 let allowed: HashSet<PolicyRule> = if cli_set.is_empty() {
253 config_set
254 } else {
255 cli_set.intersection(&config_set).cloned().collect()
256 };
257
258 let configured_zones =
259 (!mcp.allowed_zones.is_empty()).then_some(&mcp.allowed_zones);
260
261 let allowed_zones = if cli_allow_zone.is_empty() {
262 configured_zones.cloned()
263 } else if let Some(configured) = configured_zones {
264 let configured_policy = Self::new(
265 [PolicyRule::Read, PolicyRule::Write, PolicyRule::Delete],
266 Some(configured.clone()),
267 );
268 for zone in cli_allow_zone {
269 configured_policy.check_zone(zone).map_err(|_| {
270 Error::policy_violation(
271 format!(
272 "--allow-zone '{zone}' is outside this server's configured MCP allowed zones"
273 ),
274 "Remove the override or choose a zone already permitted by this server's config.",
275 )
276 })?;
277 }
278 Some(cli_allow_zone.to_vec())
279 } else {
280 Some(cli_allow_zone.to_vec())
281 };
282
283 Ok(Self::new(allowed, allowed_zones))
284 }
285
286 pub fn from_cli_and_config(cli: &Cli, config: Option<&AppConfig>) -> Result<Self> {
288 let mcp = config
289 .and_then(|c| {
290 c.selected_server(cli.servers.first().map(|s| s.as_str()))
291 .ok()
292 })
293 .map(|s| &s.mcp);
294
295 let config_set: HashSet<PolicyRule> = mcp
296 .map(|p| p.access.iter().cloned().collect())
297 .unwrap_or_else(|| {
298 [PolicyRule::Read, PolicyRule::Write, PolicyRule::Delete]
299 .into_iter()
300 .collect()
301 });
302
303 let cli_set: HashSet<PolicyRule> = cli.access.iter().cloned().collect();
304
305 let allowed: HashSet<PolicyRule> = if cli_set.is_empty() {
306 config_set
307 } else {
308 cli_set.intersection(&config_set).cloned().collect()
309 };
310
311 let allowed_zones = Self::allowed_zones_from_cli_and_mcp(cli, mcp)?;
312 Ok(Self::new(allowed, allowed_zones))
313 }
314
315 pub fn allowed_zones_from_cli_and_mcp(
317 cli: &Cli,
318 mcp: Option<&McpPermissions>,
319 ) -> Result<Option<Vec<String>>> {
320 let configured = mcp.and_then(|permissions| {
321 (!permissions.allowed_zones.is_empty()).then_some(&permissions.allowed_zones)
322 });
323
324 if cli.allow_zone.is_empty() {
325 return Ok(configured.cloned());
326 }
327
328 let Some(configured) = configured else {
329 return Ok(Some(cli.allow_zone.clone()));
330 };
331
332 let configured_policy = Self::new(
333 [PolicyRule::Read, PolicyRule::Write, PolicyRule::Delete],
334 Some(configured.clone()),
335 );
336 for zone in &cli.allow_zone {
337 configured_policy.check_zone(zone).map_err(|_| {
338 Error::policy_violation(
339 format!(
340 "--allow-zone '{zone}' is outside this server's configured MCP allowed zones"
341 ),
342 "Remove the override or choose a zone already permitted by this server's config.",
343 )
344 })?;
345 }
346
347 Ok(Some(cli.allow_zone.clone()))
348 }
349}
350
351#[cfg(test)]
354mod tests {
355 use super::*;
356 use rstest::{fixture, rstest};
357
358 #[fixture]
359 fn unrestricted() -> Policy {
360 Policy::new([PolicyRule::Read, PolicyRule::Write, PolicyRule::Delete], None)
361 }
362
363 #[fixture]
364 fn readonly() -> Policy {
365 Policy::new([PolicyRule::Read], None)
366 }
367
368 #[fixture]
369 fn write_access() -> Policy {
370 Policy::new([PolicyRule::Read, PolicyRule::Write], None)
371 }
372
373 #[fixture]
374 fn write_only() -> Policy {
375 Policy::new([PolicyRule::Write], None)
376 }
377
378 #[fixture]
379 fn write_delete() -> Policy {
380 Policy::new([PolicyRule::Write, PolicyRule::Delete], None)
381 }
382
383 #[fixture]
384 fn zone_restricted() -> Policy {
385 Policy::new(
386 [PolicyRule::Read, PolicyRule::Write, PolicyRule::Delete],
387 Some(vec!["example.com".into(), "internal.lan".into()]),
388 )
389 }
390
391 #[fixture]
392 fn both() -> Policy {
393 Policy::new([PolicyRule::Read], Some(vec!["example.com".into()]))
394 }
395
396 #[rstest]
399 fn unrestricted_allows_reads(unrestricted: Policy) {
400 assert!(unrestricted.check_read().is_ok());
401 }
402
403 #[rstest]
404 fn unrestricted_allows_writes(unrestricted: Policy) {
405 assert!(unrestricted.check_write().is_ok());
406 }
407
408 #[rstest]
409 fn unrestricted_allows_deletes(unrestricted: Policy) {
410 assert!(unrestricted.check_delete().is_ok());
411 }
412
413 #[rstest]
414 fn readonly_allows_reads(readonly: Policy) {
415 assert!(readonly.check_read().is_ok());
416 }
417
418 #[rstest]
419 fn readonly_blocks_writes(readonly: Policy) {
420 let err = readonly.check_write().unwrap_err();
421 assert!(matches!(err, Error::PolicyViolation { .. }));
422 }
423
424 #[rstest]
425 fn readonly_blocks_deletes(readonly: Policy) {
426 assert!(readonly.check_delete().is_err());
427 }
428
429 #[rstest]
430 fn write_access_allows_writes(write_access: Policy) {
431 assert!(write_access.check_write().is_ok());
432 }
433
434 #[rstest]
435 fn write_access_blocks_deletes(write_access: Policy) {
436 let err = write_access.check_delete().unwrap_err();
437 assert!(matches!(err, Error::PolicyViolation { .. }));
438 }
439
440 #[rstest]
441 fn write_only_blocks_reads(write_only: Policy) {
442 let err = write_only.check_read().unwrap_err();
443 assert!(matches!(err, Error::PolicyViolation { .. }));
444 assert!(err.to_string().contains("read"));
445 }
446
447 #[rstest]
448 fn write_only_allows_writes(write_only: Policy) {
449 assert!(write_only.check_write().is_ok());
450 }
451
452 #[rstest]
453 fn write_only_blocks_deletes(write_only: Policy) {
454 let err = write_only.check_delete().unwrap_err();
455 assert!(matches!(err, Error::PolicyViolation { .. }));
456 }
457
458 #[rstest]
459 fn write_delete_allows_writes(write_delete: Policy) {
460 assert!(write_delete.check_write().is_ok());
461 }
462
463 #[rstest]
464 fn write_delete_allows_deletes(write_delete: Policy) {
465 assert!(write_delete.check_delete().is_ok());
466 }
467
468 #[rstest]
469 fn write_delete_blocks_reads(write_delete: Policy) {
470 let err = write_delete.check_read().unwrap_err();
471 assert!(matches!(err, Error::PolicyViolation { .. }));
472 assert!(err.to_string().contains("read"));
473 }
474
475 #[rstest]
476 fn zone_restricted_allows_writes(zone_restricted: Policy) {
477 assert!(zone_restricted.check_write().is_ok());
478 }
479
480 #[rstest]
481 fn zone_restricted_allows_deletes(zone_restricted: Policy) {
482 assert!(zone_restricted.check_delete().is_ok());
483 }
484
485 #[rstest]
486 fn both_blocks_writes(both: Policy) {
487 assert!(both.check_write().is_err());
488 }
489
490 #[rstest]
493 fn unrestricted_allows_any_zone(unrestricted: Policy) {
494 assert!(unrestricted.check_zone("anything.example.com").is_ok());
495 assert!(unrestricted.check_zone("other.net").is_ok());
496 }
497
498 #[rstest]
499 fn exact_zone_match_is_allowed(zone_restricted: Policy) {
500 assert!(zone_restricted.check_zone("example.com").is_ok());
501 assert!(zone_restricted.check_zone("internal.lan").is_ok());
502 }
503
504 #[rstest]
505 fn subdomain_of_allowed_zone_is_allowed(zone_restricted: Policy) {
506 assert!(zone_restricted.check_zone("sub.example.com").is_ok());
507 assert!(zone_restricted.check_zone("deep.sub.internal.lan").is_ok());
508 }
509
510 #[rstest]
511 fn zone_check_is_case_insensitive(zone_restricted: Policy) {
512 assert!(zone_restricted.check_zone("EXAMPLE.COM").is_ok());
513 assert!(zone_restricted.check_zone("Sub.Example.Com").is_ok());
514 }
515
516 #[rstest]
517 fn disallowed_zone_is_rejected(zone_restricted: Policy) {
518 let err = zone_restricted.check_zone("other.net").unwrap_err();
519 assert!(matches!(err, Error::PolicyViolation { .. }));
520 assert!(err.to_string().contains("other.net"));
521 }
522
523 #[rstest]
524 fn partial_suffix_without_dot_is_not_allowed(zone_restricted: Policy) {
525 assert!(zone_restricted.check_zone("notexample.com").is_err());
527 }
528
529 #[rstest]
532 fn unrestricted_has_no_suffix(unrestricted: Policy) {
533 assert!(unrestricted.instructions_suffix().is_empty());
534 }
535
536 #[rstest]
537 fn readonly_suffix_mentions_read_only(readonly: Policy) {
538 assert!(readonly.instructions_suffix().contains("Read-only"));
539 }
540
541 #[rstest]
542 fn write_access_suffix_mentions_write_mode(write_access: Policy) {
543 assert!(write_access.instructions_suffix().contains("Write mode: delete operations are disabled."));
544 }
545
546 #[rstest]
547 fn write_only_suffix_mentions_write_only(write_only: Policy) {
548 assert!(write_only.instructions_suffix().contains("Write-only"));
549 }
550
551 #[rstest]
552 fn write_delete_suffix_mentions_read_disabled(write_delete: Policy) {
553 assert!(write_delete.instructions_suffix().contains("read operations are disabled"));
554 }
555
556 #[rstest]
557 fn zone_restricted_suffix_mentions_zones(zone_restricted: Policy) {
558 let s = zone_restricted.instructions_suffix();
559 assert!(s.contains("example.com"));
560 assert!(s.contains("internal.lan"));
561 }
562
563 #[rstest]
564 fn both_suffix_mentions_both(both: Policy) {
565 let s = both.instructions_suffix();
566 assert!(s.contains("Read-only"));
567 assert!(s.contains("example.com"));
568 }
569
570 use crate::control_plane::config::{DnsServerConfig, McpPermissions, VendorKind};
573
574 fn server_with_mcp(access: Vec<PolicyRule>, allowed_zones: Vec<String>) -> DnsServerConfig {
589 DnsServerConfig {
590 id: "test".into(),
591 vendor: VendorKind::Technitium,
592 location: None,
593 base_url: None,
594 base_url_env: None,
595 token: Some("tok".into()),
596 token_env: None,
597 org_id: None,
598 cluster: None,
599 dns: None,
600 dot: None,
601 doh: None,
602 mcp: McpPermissions { access, allowed_zones },
603 validation_endpoints: vec![],
604 }
605 }
606
607 #[test]
608 fn for_server_uses_mcp_access_when_cli_access_empty() {
609 let server = server_with_mcp(vec![PolicyRule::Read], vec![]);
610 let policy = Policy::for_server(&server, &[], &[]).unwrap();
611 assert!(policy.check_read().is_ok());
612 assert!(policy.check_write().is_err());
613 assert!(policy.check_delete().is_err());
614 }
615
616 #[test]
617 fn for_server_intersects_cli_access_with_mcp_access() {
618 let server = server_with_mcp(
619 vec![PolicyRule::Read, PolicyRule::Write],
620 vec![],
621 );
622 let policy =
624 Policy::for_server(&server, &[PolicyRule::Read, PolicyRule::Delete], &[]).unwrap();
625 assert!(policy.check_read().is_ok());
626 assert!(policy.check_write().is_err());
627 assert!(policy.check_delete().is_err());
628 }
629
630 #[test]
631 fn for_server_cli_access_cannot_broaden_mcp_access() {
632 let server = server_with_mcp(vec![PolicyRule::Read], vec![]);
633 let policy = Policy::for_server(&server, &[PolicyRule::Write], &[]).unwrap();
635 assert!(policy.check_read().is_err());
636 assert!(policy.check_write().is_err());
637 }
638
639 #[test]
640 fn for_server_cli_allow_zone_narrows_mcp_zones() {
641 let server = server_with_mcp(
642 vec![PolicyRule::Read],
643 vec!["example.com".into(), "internal.lan".into()],
644 );
645 let policy =
646 Policy::for_server(&server, &[], &["example.com".to_string()]).unwrap();
647 assert!(policy.check_zone("example.com").is_ok());
648 assert!(policy.check_zone("sub.example.com").is_ok());
649 assert!(policy.check_zone("internal.lan").is_err());
650 }
651
652 #[test]
653 fn for_server_cli_allow_zone_outside_mcp_zones_is_rejected() {
654 let server = server_with_mcp(
655 vec![PolicyRule::Read],
656 vec!["example.com".into()],
657 );
658 let err =
659 Policy::for_server(&server, &[], &["other.net".to_string()]).unwrap_err();
660 assert!(matches!(err, Error::PolicyViolation { .. }));
661 assert!(err.to_string().contains("other.net"));
662 }
663
664 #[test]
665 fn for_server_unrestricted_zones_when_neither_side_configures_them() {
666 let server = server_with_mcp(vec![PolicyRule::Read, PolicyRule::Write, PolicyRule::Delete], vec![]);
667 let policy = Policy::for_server(&server, &[], &[]).unwrap();
668 assert!(policy.allowed_zones.is_none());
669 assert!(policy.check_zone("anything.example.com").is_ok());
670 }
671}