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;