bindy/reconcilers/dnszone/
validation.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! Validation logic for DNS zones.
5//!
6//! This module contains functions for validating zone configurations,
7//! checking for duplicate zones, and filtering instances.
8
9use anyhow::{anyhow, Result};
10use kube::ResourceExt;
11use tracing::{debug, warn};
12
13use super::types::{ConflictingZone, DuplicateZoneInfo};
14use crate::crd::DNSZone;
15
16/// Get instances from a DNSZone based on `bind9_instances_from` selectors.
17///
18/// This function:
19/// - Uses the reflector store for O(1) lookups without API calls
20/// - Single source of truth: `DNSZone` owns the zone-instance relationship
21///
22/// # F-003 mitigation: cross-namespace targeting requires platform-admin opt-in
23///
24/// A label selector match is *not* sufficient to enrol a `Bind9Instance` in
25/// the zone. The instance is included only when **either**:
26///
27/// 1. The instance lives in the **same namespace** as the `DNSZone`, **or**
28/// 2. The instance carries the
29///    [`crate::constants::ANNOTATION_ALLOW_ZONE_NAMESPACES`] annotation
30///    whose value contains the zone's namespace (or the wildcard
31///    [`crate::constants::ALLOW_ZONE_NAMESPACES_WILDCARD`]).
32///
33/// The annotation is metadata on the `Bind9Instance`, which is owned by
34/// the platform admin (only they have RBAC on the namespace where the
35/// instance lives). This preserves the cluster-wide-operator contract:
36/// the platform admin keeps full control of who can claim their
37/// instances, expressed through a platform-admin-controlled annotation,
38/// while still preventing the F-003 hijack — labels on the instance side
39/// are not a security boundary (they are discoverable via list/watch and
40/// any tenant can write any matchLabels they want), but annotations on
41/// the platform-owned instance are.
42///
43/// # Arguments
44///
45/// * `dnszone` - The `DNSZone` resource to get instances for
46/// * `bind9_instances_store` - Reflector store of `Bind9Instance`
47///
48/// # Returns
49///
50/// * `Ok(Vec<InstanceReference>)` - List of instances serving this zone
51/// * `Err(_)` - If no instances pass both the selector match and the
52///   namespace gate
53///
54/// # Errors
55///
56/// Returns an error if no instances pass the selector + namespace gate, or
57/// if `spec.bind9_instances_from` is missing or empty.
58pub fn get_instances_from_zone(
59    dnszone: &DNSZone,
60    bind9_instances_store: &kube::runtime::reflector::Store<crate::crd::Bind9Instance>,
61) -> Result<Vec<crate::crd::InstanceReference>> {
62    let namespace = dnszone.namespace().unwrap_or_default();
63    let name = dnszone.name_any();
64
65    // Get bind9_instances_from selectors from zone spec
66    let bind9_instances_from = match &dnszone.spec.bind9_instances_from {
67        Some(sources) if !sources.is_empty() => sources,
68        _ => {
69            return Err(anyhow!(
70                "DNSZone {namespace}/{name} has no bind9_instances_from selectors configured. \
71                Add spec.bind9_instances_from[] with label selectors to target Bind9Instance resources."
72            ));
73        }
74    };
75
76    let mut cross_ns_denied: Vec<(String, String)> = Vec::new();
77    let instances_with_zone: Vec<crate::crd::InstanceReference> = bind9_instances_store
78        .state()
79        .iter()
80        .filter_map(|instance| {
81            let instance_labels = instance.metadata.labels.as_ref()?;
82            let instance_namespace = instance.namespace()?;
83            let instance_name = instance.name_any();
84
85            // Selector match (label-based) — necessary but not sufficient.
86            let matches = bind9_instances_from
87                .iter()
88                .any(|source| source.selector.matches(instance_labels));
89            if !matches {
90                return None;
91            }
92
93            // F-003 namespace gate. Same-namespace always allowed; cross-
94            // namespace requires the platform-admin annotation on the
95            // instance.
96            if instance_namespace != namespace
97                && !instance_allows_zone_namespace(instance, &namespace)
98            {
99                cross_ns_denied.push((instance_namespace.clone(), instance_name.clone()));
100                return None;
101            }
102
103            Some(crate::crd::InstanceReference {
104                api_version: "bindy.firestoned.io/v1beta1".to_string(),
105                kind: "Bind9Instance".to_string(),
106                name: instance_name,
107                namespace: instance_namespace,
108                last_reconciled_at: None,
109            })
110        })
111        .collect();
112
113    if !cross_ns_denied.is_empty() {
114        warn!(
115            "DNSZone {}/{} label selectors matched {} cross-namespace Bind9Instance(s) \
116             that were rejected by the F-003 namespace gate: {:?}. \
117             To allow cross-namespace targeting, the platform admin must annotate the \
118             target Bind9Instance with `{}: <comma-separated namespaces>` (or `*`).",
119            namespace,
120            name,
121            cross_ns_denied.len(),
122            cross_ns_denied,
123            crate::constants::ANNOTATION_ALLOW_ZONE_NAMESPACES,
124        );
125    }
126
127    if !instances_with_zone.is_empty() {
128        debug!(
129            "DNSZone {}/{} matched {} instances via spec.bind9_instances_from selectors",
130            namespace,
131            name,
132            instances_with_zone.len()
133        );
134        return Ok(instances_with_zone);
135    }
136
137    // No instances found — message distinguishes "no labels matched" from
138    // "labels matched but cross-namespace gate denied them".
139    if cross_ns_denied.is_empty() {
140        Err(anyhow!(
141            "DNSZone {namespace}/{name} has no instances matching spec.bind9_instances_from selectors. \
142            Verify that Bind9Instance resources exist with matching labels."
143        ))
144    } else {
145        Err(anyhow!(
146            "DNSZone {namespace}/{name} matched only cross-namespace Bind9Instance(s) \
147             that the F-003 namespace gate denied. Ask the platform admin to annotate \
148             the target instance with `{annotation}: {namespace}` (or `{annotation}: *` \
149             to allow any namespace).",
150            annotation = crate::constants::ANNOTATION_ALLOW_ZONE_NAMESPACES,
151        ))
152    }
153}
154
155/// Check whether `instance` carries an annotation that grants the named
156/// `zone_namespace` permission to target it cross-namespace.
157///
158/// Returns `true` iff the instance's
159/// [`crate::constants::ANNOTATION_ALLOW_ZONE_NAMESPACES`] annotation is
160/// set and its value, when parsed as a comma-separated list, contains
161/// either `zone_namespace` or
162/// [`crate::constants::ALLOW_ZONE_NAMESPACES_WILDCARD`].
163///
164/// Same-namespace matching is handled by the caller and does *not*
165/// require this annotation.
166#[must_use]
167pub fn instance_allows_zone_namespace(
168    instance: &crate::crd::Bind9Instance,
169    zone_namespace: &str,
170) -> bool {
171    let Some(annotations) = instance.metadata.annotations.as_ref() else {
172        return false;
173    };
174    let Some(value) = annotations.get(crate::constants::ANNOTATION_ALLOW_ZONE_NAMESPACES) else {
175        return false;
176    };
177    value.split(',').map(str::trim).any(|entry| {
178        entry == crate::constants::ALLOW_ZONE_NAMESPACES_WILDCARD || entry == zone_namespace
179    })
180}
181
182/// Checks if another zone has already claimed the same zone name across any BIND9 instances.
183///
184/// This function prevents multiple teams from creating conflicting zones with the same
185/// fully qualified domain name (FQDN). A conflict exists if:
186/// 1. Another DNSZone CR has the same `spec.zoneName`
187/// 2. That zone is NOT the same resource (different namespace/name)
188/// 3. That zone has at least one instance configured (status.bind9Instances is non-empty)
189/// 4. Those instances have status != "Failed"
190///
191/// # Arguments
192///
193/// * `dnszone` - The DNSZone resource to check for duplicates
194/// * `zones_store` - The reflector store containing all DNSZone resources
195///
196/// # Returns
197///
198/// * `Some(DuplicateZoneInfo)` - If a duplicate zone is detected, with details about conflicts
199/// * `None` - If no duplicate exists (safe to proceed)
200///
201/// # Examples
202///
203/// ```rust,ignore
204/// use tracing::warn;
205/// use bindy::reconcilers::dnszone::check_for_duplicate_zones;
206///
207/// if let Some(duplicate_info) = check_for_duplicate_zones(&dnszone, &zones_store) {
208///     warn!("Zone {} conflicts with existing zones: {:?}",
209///           duplicate_info.zone_name, duplicate_info.conflicting_zones);
210///     // Set status condition to DuplicateZone and stop processing
211/// }
212/// ```
213pub fn check_for_duplicate_zones(
214    dnszone: &DNSZone,
215    zones_store: &kube::runtime::reflector::Store<DNSZone>,
216) -> Option<DuplicateZoneInfo> {
217    let current_namespace = dnszone.namespace().unwrap_or_default();
218    let current_name = dnszone.name_any();
219    let zone_name = &dnszone.spec.zone_name;
220
221    debug!(
222        "Checking for duplicate zones: current zone {}/{} claims {}",
223        current_namespace, current_name, zone_name
224    );
225
226    // F-003 mitigation: switch the duplicate check from a status-based gate
227    // to a spec-based one. The previous implementation only flagged a
228    // conflict if the *other* zone had `status.bind9_instances` non-empty
229    // and at least one instance not in `Failed`/`Unclaimed`. That left
230    // every race window open: a tenant who created their malicious zone
231    // *first*, before the legitimate zone reached `Configured` state,
232    // would claim the zoneName uncontested, and the legitimate zone would
233    // never reconcile. We now compare on `spec.zoneName` directly and use
234    // creation timestamp to break ties: the *older* CR wins.
235    let current_creation = dnszone.metadata.creation_timestamp.as_ref();
236
237    let mut conflicting_zones = Vec::new();
238
239    for other_zone in &zones_store.state() {
240        let other_namespace = other_zone.namespace().unwrap_or_default();
241        let other_name = other_zone.name_any();
242
243        // Skip if this is the same zone (updating itself).
244        if other_namespace == current_namespace && other_name == current_name {
245            continue;
246        }
247
248        // Skip if zone name doesn't match.
249        if other_zone.spec.zone_name != *zone_name {
250            continue;
251        }
252
253        // Tie-break by creation timestamp: the *older* CR keeps the
254        // zoneName; the newer one is the conflict. If timestamps are
255        // missing or equal, fall back to a stable lexicographic order on
256        // (namespace, name) so the result is deterministic.
257        let other_creation = other_zone.metadata.creation_timestamp.as_ref();
258        let other_is_older = match (other_creation, current_creation) {
259            (Some(o), Some(c)) if o.0 != c.0 => o.0 < c.0,
260            _ => {
261                (other_namespace.as_str(), other_name.as_str())
262                    < (current_namespace.as_str(), current_name.as_str())
263            }
264        };
265        if !other_is_older {
266            // The current zone is the older / lexicographically first
267            // claimant — keep it; the *other* zone is the loser. Don't
268            // record this as a conflict here; the other zone's own
269            // reconciler will report its loss when it runs.
270            continue;
271        }
272
273        // Collect any instance names from status for the operator's
274        // diagnostics, but do not gate the conflict on them.
275        let instance_names = other_zone
276            .status
277            .as_ref()
278            .map(|status| {
279                status
280                    .bind9_instances
281                    .iter()
282                    .filter(|inst| {
283                        inst.status != crate::crd::InstanceStatus::Failed
284                            && inst.status != crate::crd::InstanceStatus::Unclaimed
285                    })
286                    .map(|inst| format!("{}/{}", inst.namespace, inst.name))
287                    .collect()
288            })
289            .unwrap_or_default();
290
291        warn!(
292            "Duplicate zone detected: {}/{} already claims {} (older CR or lex-prior); \
293             current zone {}/{} will be marked Ready=False with DuplicateZone reason. \
294             Instances on the winning zone: {:?}",
295            other_namespace, other_name, zone_name, current_namespace, current_name, instance_names
296        );
297
298        conflicting_zones.push(ConflictingZone {
299            name: other_name,
300            namespace: other_namespace,
301            instance_names,
302        });
303    }
304
305    if conflicting_zones.is_empty() {
306        None
307    } else {
308        Some(DuplicateZoneInfo {
309            zone_name: zone_name.clone(),
310            conflicting_zones,
311        })
312    }
313}
314
315/// Filters instances that need reconciliation based on their `last_reconciled_at` timestamp.
316///
317/// Returns instances where:
318/// - `last_reconciled_at` is `None` (never reconciled)
319/// - `last_reconciled_at` exists but we need to verify pod IPs haven't changed
320///
321/// # Arguments
322///
323/// * `instances` - All instances assigned to the zone
324///
325/// # Returns
326///
327/// List of instances that need reconciliation (zone configuration)
328#[must_use]
329pub fn filter_instances_needing_reconciliation(
330    instances: &[crate::crd::InstanceReference],
331) -> Vec<crate::crd::InstanceReference> {
332    instances
333        .iter()
334        .filter(|instance| {
335            // If never reconciled, needs reconciliation
336            instance.last_reconciled_at.is_none()
337        })
338        .cloned()
339        .collect()
340}
341
342#[cfg(test)]
343#[path = "validation_tests.rs"]
344mod validation_tests;