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