Skip to main content

dnslib/control_plane/
policy.rs

1//! Guardrail policy for the MCP server.
2//!
3//! Policy is evaluated before any tool call dispatches to `dns::*`.
4//! Config, CLI, and env vars are the source of truth — callers of `DnsServer::new`
5//! must construct a `Policy` for the selected DNS server and pass it in.
6//!
7//! # Operation sets
8//!
9//! A `Policy` holds an explicit set of allowed `PolicyRule` variants.
10//! Rules are independent: you can permit any combination of Read, Write, and Delete.
11//!
12//! - **Read**: list/export/stats/settings/cache-browse tools are permitted.
13//! - **Write**: create/update/import/flush/block/allow tools are permitted.
14//! - **Delete**: delete tools are permitted.
15//! - **Zone allow-list**: any tool that targets a specific zone is rejected unless
16//!   that zone (or its parent) is in the allow-list. Zone-agnostic tools (stats,
17//!   settings, cache browse) are always permitted.
18
19use 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/// Identifies a single class of DNS operation.
29///
30/// A `Policy` holds a `HashSet<PolicyRule>` — only operations whose rule is
31/// present in that set are permitted.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ValueEnum)]
33#[serde(rename_all = "lowercase")]
34pub enum PolicyRule {
35    /// Read-only operations: list zones/records, export, stats, settings, cache browse.
36    Read,
37    /// Write operations: create/update/import/flush/block/allow.
38    Write,
39    /// Delete operations: delete zone/record/cache/block/allow entries.
40    Delete,
41}
42
43/// Governs what the MCP server is permitted to do.
44#[derive(Debug, Clone)]
45pub struct Policy {
46    /// Set of permitted operation classes.
47    pub allowed: HashSet<PolicyRule>,
48
49    /// If `Some`, only zones in this list (case-insensitive) are accessible.
50    /// `None` means unrestricted.
51    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    /// Construct a new policy from its constituent parts.
65    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    /// Assert that the active policy permits read operations.
106    /// Shorthand for `check(PolicyRule::Read)`.
107    pub fn check_read(&self) -> Result<()> {
108        self.check(PolicyRule::Read)
109    }
110
111    /// Assert that the active policy permits write operations.
112    /// Shorthand for `check(PolicyRule::Write)`.
113    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    /// Returns a human-readable summary of active restrictions, used in the
143    /// MCP `ServerInfo.instructions` field so Claude knows upfront what it can do.
144    pub fn instructions_suffix(&self) -> String {
145        let mut parts = Vec::new();
146
147        // Collect disabled operations (those NOT in self.allowed)
148        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            // Check for common named combinations for human-friendly messages
161            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                // only delete allowed — unusual but possible
167                parts.push(
168                    "⚠️  Restricted mode: read and write operations are disabled.".to_string(),
169                );
170            } else if read_disabled && delete_disabled && !write_disabled {
171                // write-only
172                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                // read-only
177                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                // write+delete mode (read disabled) — write mode with read blocked
182                parts.push("⚠️  Write mode: read operations are disabled.".to_string());
183            } else if delete_disabled && !read_disabled && !write_disabled {
184                // read+write mode (delete disabled) — write mode without deletes
185                parts.push("⚠️  Write mode: delete operations are disabled.".to_string());
186            } else {
187                // Generic fallback: list the disabled operations
188                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    /// Constructs an effective `Policy` for a single DNS server by combining the server's MCP
212    /// access configuration with CLI-provided access and zone overrides.
213    ///
214    /// - Operation permissions: if `cli_access` is empty the server's MCP `access` is used;
215    ///   otherwise the resulting allowed operations are the intersection of `cli_access` and the
216    ///   server's MCP `access` (the CLI cannot broaden permissions beyond the server's config).
217    /// - Zone restrictions: if `cli_allow_zone` is empty the server's configured `allowed_zones`
218    ///   (if any) is used; if `cli_allow_zone` is non-empty it becomes the resulting zone list.
219    ///   When the server has configured allowed zones, each CLI-provided zone is validated against
220    ///   the server's allowed zones (subdomains and case-insensitive matches are permitted);
221    ///   a CLI zone outside the server's configured zones causes a `PolicyViolation` error.
222    ///
223    /// # Errors
224    ///
225    /// Returns `Error::PolicyViolation` when any entry in `cli_allow_zone` is not permitted by the
226    /// server's MCP configured allowed zones (when that restriction exists).
227    ///
228    /// # Examples
229    ///
230    /// ```ignore
231    /// use crate::control_plane::policy::Policy;
232    /// use crate::control_plane::config::DnsServerConfig;
233    ///
234    /// // Construct `server`, `cli_access`, and `cli_allow_zone` according to your application.
235    /// let server: DnsServerConfig = /* server from config */ unimplemented!();
236    /// let cli_access = vec![]; // empty means "use server MCP access"
237    /// let cli_allow_zone: Vec<String> = vec![]; // empty means "use server MCP zones"
238    ///
239    /// let policy = Policy::for_server(&server, &cli_access, &cli_allow_zone)?;
240    /// ```ignore
241    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    /// Build a `Policy` from CLI options and config.
285    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    /// Build allowed zones from CLI and MCP config.
314    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// ─── Tests ────────────────────────────────────────────────────────────────────
350
351#[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    // ── check / check_read / check_write / check_delete ──────────────────────
398
399    #[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    // ── check_zone ────────────────────────────────────────────────────────────
492
493    #[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        // "notexample.com" must NOT match allowed "example.com"
527        assert!(zone_restricted.check_zone("notexample.com").is_err());
528    }
529
530    // ── instructions_suffix ───────────────────────────────────────────────────
531
532    #[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    // ── Policy::for_server ────────────────────────────────────────────────────
580
581    use crate::control_plane::config::{DnsServerConfig, McpPermissions, VendorKind};
582
583    /// Constructs a test `DnsServerConfig` with the provided MCP permissions.
584    ///
585    /// The returned config is populated with a fixed id, vendor, token and the given
586    /// `access` and `allowed_zones` embedded in `mcp`. Other fields are left as
587    /// None or empty suitable for unit tests.
588    ///
589    /// # Examples
590    ///
591    /// ```ignore
592    /// let cfg = server_with_mcp(vec![PolicyRule::Read, PolicyRule::Write], vec!["example.com".into()]);
593    /// assert_eq!(cfg.id, "test");
594    /// assert_eq!(cfg.mcp.allowed_zones.len(), 1);
595    /// assert!(cfg.mcp.access.contains(&PolicyRule::Read));
596    /// ```ignore
597    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        // CLI requests read+delete but server only allows read+write → intersection is read only
634        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        // CLI asks for write but server config only permits read → result is still read-only
645        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}