Skip to main content

kopiur_api/validate/
backend.rs

1use super::*;
2use crate::error::{ValidationError, ValidationResult};
3
4/// A DNS-1123 subdomain (the shape of every Kubernetes object name): non-empty,
5/// ≤253 chars, lowercase alphanumerics / `-` / `.`, starting and ending
6/// alphanumeric. The structural schema can't express it, so the webhook does.
7/// `field` names where the value appears, for an actionable message.
8pub fn validate_dns1123_name(value: &str, field: &str) -> ValidationResult {
9    if value.is_empty() {
10        return Err(ValidationError::MissingRequiredField {
11            field: field.to_string(),
12        });
13    }
14    let valid_len = value.len() <= 253;
15    let valid_chars = value
16        .chars()
17        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '.');
18    let valid_edges = value.starts_with(|c: char| c.is_ascii_alphanumeric())
19        && value.ends_with(|c: char| c.is_ascii_alphanumeric());
20    if valid_len && valid_chars && valid_edges {
21        Ok(())
22    } else {
23        Err(ValidationError::InvalidFieldValue {
24            field: field.to_string(),
25            reason: format!(
26                "must be a DNS-1123 subdomain: lowercase letters, digits, '-' or '.', \
27                 starting and ending with a letter or digit, at most 253 characters \
28                 (got {value:?})"
29            ),
30        })
31    }
32}
33
34/// A cloud-IAM backend's `auth` block is well-formed: **exactly one** of
35/// `secretRef` or `workloadIdentity` when `auth` is present (both are `Option`
36/// because the forms share the `auth` key, so it's a webhook check — the same
37/// shape as [`validate_source`]). An absent/empty `auth` is legal: the
38/// well-known keys may ride the encryption-password Secret, and an empty block
39/// means exactly that. A workload-identity `serviceAccountName` must be a valid
40/// object name, or the mover Job would be rejected by the API server later with
41/// a far less actionable message. `context` names the backend (e.g.
42/// `"s3 backend"`) for the message.
43pub fn validate_backend_auth(
44    auth: &crate::backend::BackendAuth,
45    context: &str,
46) -> ValidationResult {
47    if auth.secret_ref.is_some() && auth.workload_identity.is_some() {
48        return Err(ValidationError::MutuallyExclusive {
49            a: "auth.secretRef".to_string(),
50            b: "auth.workloadIdentity".to_string(),
51            context: context.to_string(),
52        });
53    }
54    if let Some(wi) = &auth.workload_identity {
55        validate_dns1123_name(
56            &wi.service_account_name,
57            &format!("{context} auth.workloadIdentity.serviceAccountName"),
58        )?;
59    }
60    Ok(())
61}
62
63/// A backend's `tls` block is internally consistent — every rule the structural
64/// schema can't express. A `caBundleRef` must actually name a ConfigMap
65/// (`configMapName` was `Option` for API growth, so an empty `caBundleRef: {}`
66/// parses fine but would be a silently dead reference), the name must be a
67/// valid object name (or every mover run fails at ConfigMap resolution with a
68/// far less actionable message), an explicitly-set `key` must not be blank
69/// (blank would shadow the `ca.crt` default and never match a real key), and
70/// pairing `caBundleRef` with `disableTls: true` is a contradiction: with
71/// kopia's `--disable-tls` there is no TLS handshake at all, so the CA could
72/// never be consulted. `context` names the backend (e.g. `"s3 backend"`) for
73/// the message.
74///
75/// Deliberately NOT here: `caBundleRef` + `insecureSkipVerify: true` is an
76/// admission *warning* ([`super::S3_TLS_SKIP_VERIFY_WARNING`]), never an error
77/// — see that constant's doc for why upgrades forbid hardening it.
78pub fn validate_backend_tls(tls: &crate::common::TlsConfig, context: &str) -> ValidationResult {
79    let Some(ca) = &tls.ca_bundle_ref else {
80        return Ok(());
81    };
82    if tls.disable_tls {
83        return Err(ValidationError::MutuallyExclusive {
84            a: "tls.caBundleRef".to_string(),
85            b: "tls.disableTls".to_string(),
86            context: format!(
87                "{context}: disableTls means the CA bundle is never used; remove \
88                 disableTls, or remove caBundleRef for plain HTTP"
89            ),
90        });
91    }
92    match ca.config_map_name.as_deref() {
93        None | Some("") => {
94            return Err(ValidationError::InvalidFieldValue {
95                field: format!("{context} tls.caBundleRef.configMapName"),
96                reason: "is required. Fix: set configMapName to the ConfigMap holding \
97                         the PEM CA bundle, or remove the caBundleRef block"
98                    .to_string(),
99            });
100        }
101        Some(name) => {
102            validate_dns1123_name(name, &format!("{context} tls.caBundleRef.configMapName"))?;
103        }
104    }
105    if let Some(key) = &ca.key
106        && key.trim().is_empty()
107    {
108        return Err(ValidationError::InvalidFieldValue {
109            field: format!("{context} tls.caBundleRef.key"),
110            reason: format!(
111                "is blank ({key:?}). Fix: set it to the key holding the PEM CA \
112                 bundle, or omit it to use the default \"ca.crt\""
113            ),
114        });
115    }
116    Ok(())
117}
118
119/// Validate backend *content* the structural schema can't express: the
120/// inline-NFS volume on a `Filesystem` backend, the `secretRef` XOR
121/// `workloadIdentity` rule on the cloud-IAM backends, Azure's
122/// workload-identity prerequisites, and the S3 `tls` block's consistency.
123/// Exhaustive `match` so a new `Backend` variant must be considered here
124/// before it compiles.
125pub fn validate_backend(backend: &crate::backend::Backend) -> ValidationResult {
126    use crate::backend::{Backend, RepoVolume};
127    match backend {
128        Backend::Filesystem(fs) => match &fs.volume {
129            Some(RepoVolume::Nfs(nfs)) => validate_nfs_volume(nfs, "filesystem repo"),
130            Some(RepoVolume::Pvc(_)) | None => Ok(()),
131        },
132        Backend::S3(s) => {
133            if let Some(auth) = &s.auth {
134                validate_backend_auth(auth, "s3 backend")?;
135            }
136            if let Some(tls) = &s.tls {
137                validate_backend_tls(tls, "s3 backend")?;
138            }
139            Ok(())
140        }
141        Backend::Azure(a) => match &a.auth {
142            Some(auth) => {
143                validate_backend_auth(auth, "azure backend")?;
144                // kopia's `--storage-account` is a required flag, and with
145                // workload identity there is no Secret to deliver it via the
146                // AZURE_STORAGE_ACCOUNT env var (the azure-workload-identity
147                // webhook injects only tenant/client/token-file). It must be in
148                // the spec, or every mover run fails at kopia flag parsing.
149                if auth.workload_identity.is_some() && a.storage_account.is_none() {
150                    return Err(ValidationError::InvalidFieldValue {
151                        field: "azure backend storageAccount".to_string(),
152                        reason: "is required with auth.workloadIdentity. Fix: set \
153                                 spec.backend.azure.storageAccount"
154                            .to_string(),
155                    });
156                }
157                Ok(())
158            }
159            None => Ok(()),
160        },
161        Backend::Gcs(g) => match &g.auth {
162            Some(auth) => validate_backend_auth(auth, "gcs backend"),
163            None => Ok(()),
164        },
165        Backend::Rclone(r) => {
166            // kopia's `--rclone-startup-timeout` takes a Go duration; reject a
167            // malformed value at admission instead of failing every connect.
168            if let Some(t) = &r.startup_timeout
169                && crate::duration::parse_go_duration(t).is_none()
170            {
171                return Err(ValidationError::InvalidFieldValue {
172                    field: "rclone backend startupTimeout".to_string(),
173                    reason: format!("must be a Go duration like \"30s\" or \"2m\" (got {t:?})"),
174                });
175            }
176            Ok(())
177        }
178        Backend::Gdrive(g) => {
179            if g.folder_id.trim().is_empty() {
180                return Err(ValidationError::MissingRequiredField {
181                    field: "gdrive backend folderId".to_string(),
182                });
183            }
184            Ok(())
185        }
186        Backend::B2(_) | Backend::Sftp(_) | Backend::WebDav(_) => Ok(()),
187    }
188}
189
190/// WHICH one-pod credential pairing [`validate_replication_auth`] is judging.
191///
192/// The rule itself is identical everywhere a single mover pod carries two
193/// backends' credentials, so exactly one implementation decides it. Only the
194/// FIELD PATHS and the prose differ, and they differ enough to matter: a
195/// repository bootstrap rejection that says "destination backend auth" and
196/// blames "the replication mover" names a field the author never wrote and a
197/// mover that never ran. An enum rather than free-form strings so a new pairing
198/// cannot be added without deciding what its rejection says.
199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
200pub enum AuthPairKind {
201    /// A `RepositoryReplication` / `SnapshotReplication` source→destination
202    /// pair, carried by one replication mover Job.
203    Replication,
204    /// A repository's own `spec.backend` paired with its `spec.seed` source,
205    /// carried by one seeding Job at bootstrap. Note the argument order this
206    /// implies: the `source` argument is the repository being created and the
207    /// `destination` argument is the seed it reads FROM.
208    Seed,
209}
210
211impl AuthPairKind {
212    /// Field path for the both-federated, disagreeing-ServiceAccount rejection.
213    fn service_account_field(self) -> &'static str {
214        match self {
215            Self::Replication => "destination auth.workloadIdentity.serviceAccountName",
216            Self::Seed => "seed.from.backend auth.workloadIdentity.serviceAccountName",
217        }
218    }
219
220    /// Field path for the static/workload-identity mix rejection.
221    fn auth_field(self) -> &'static str {
222        match self {
223            Self::Replication => "destination backend auth",
224            Self::Seed => "seed.from.backend auth",
225        }
226    }
227
228    /// The single pod that carries both credential sets, as a noun phrase (also
229    /// used possessively, so it must read correctly with a trailing `'s`).
230    fn mover(self) -> &'static str {
231        match self {
232            Self::Replication => "the replication mover",
233            Self::Seed => "the seeding mover",
234        }
235    }
236
237    /// How to name the `source` argument's side in prose.
238    fn source_label(self) -> &'static str {
239        match self {
240            Self::Replication => "the source repository",
241            Self::Seed => "this repository",
242        }
243    }
244
245    /// How to name the `destination` argument's side in prose.
246    fn destination_label(self) -> &'static str {
247        match self {
248            Self::Replication => "the destination",
249            Self::Seed => "the seed source",
250        }
251    }
252
253    /// The pairing itself, for "a same-kind {} pair cannot mix ...".
254    fn pair_label(self) -> &'static str {
255        match self {
256            Self::Replication => "source/destination",
257            Self::Seed => "repository/seed-source",
258        }
259    }
260}
261
262/// A `RepositoryReplication`'s source/destination auth pair is safe to run in
263/// **one** mover pod. The replicate pod's environment carries the static side's
264/// credential Secret (`envFrom`); for a same-kind S3 or Azure pair where exactly
265/// one side uses workload identity, the workload-identity side's credential
266/// chain reads those same env vars (minio-go's `EnvAWS`; kopia's env-bound azure
267/// flags) and would silently authenticate as the *other* side — wrong identity,
268/// plausibly wrong permissions, no error. Rejected at admission instead. GCS
269/// mixed pairs are safe (the static side's key travels as a `--credentials-file`
270/// path, not ambient env). Both-workload-identity pairs must name the same
271/// ServiceAccount — a pod runs as exactly one.
272///
273/// A repository `spec.seed` reuses this VERDICT unchanged — one seeding pod
274/// carries both credential sets for exactly the same reason — and passes
275/// [`AuthPairKind::Seed`] so the rejection points at `spec.seed.from.backend`
276/// and speaks about the seeding Job (issue #380).
277pub fn validate_replication_auth(
278    source: &crate::backend::Backend,
279    destination: &crate::backend::Backend,
280    kind: AuthPairKind,
281) -> ValidationResult {
282    use crate::creds::{WorkloadIdentityCloud, backend_workload_identity};
283    let src_wi = backend_workload_identity(source);
284    let dst_wi = backend_workload_identity(destination);
285    match (src_wi, dst_wi) {
286        (None, None) => Ok(()),
287        (Some((a, _)), Some((b, _))) => {
288            if a.service_account_name == b.service_account_name {
289                Ok(())
290            } else {
291                Err(ValidationError::InvalidFieldValue {
292                    field: kind.service_account_field().to_string(),
293                    reason: format!(
294                        "{mover} runs as one ServiceAccount, but {src} uses {a:?} \
295                         and {dst} uses {b:?}. Fix: use the same ServiceAccount \
296                         (with IAM access to both stores)",
297                        mover = kind.mover(),
298                        src = kind.source_label(),
299                        dst = kind.destination_label(),
300                        a = a.service_account_name,
301                        b = b.service_account_name,
302                    ),
303                })
304            }
305        }
306        (Some((_, wi_cloud)), None) | (None, Some((_, wi_cloud))) => {
307            let static_side = if src_wi.is_some() {
308                destination
309            } else {
310                source
311            };
312            let conflicts = match wi_cloud {
313                WorkloadIdentityCloud::S3 => {
314                    matches!(static_side, crate::backend::Backend::S3(_))
315                }
316                WorkloadIdentityCloud::Azure => {
317                    matches!(static_side, crate::backend::Backend::Azure(_))
318                }
319                // GCS static keys travel as a --credentials-file path, never
320                // ambient env, so they cannot leak into the ADC chain.
321                WorkloadIdentityCloud::Gcs => false,
322            };
323            if conflicts {
324                Err(ValidationError::InvalidFieldValue {
325                    field: kind.auth_field().to_string(),
326                    reason: format!(
327                        "a {pair} pair cannot mix workloadIdentity with a static \
328                         credential Secret: {mover} would authenticate with the \
329                         wrong one. Fix: use workloadIdentity on both sides or \
330                         static Secrets on both",
331                        pair = kind.pair_label(),
332                        mover = kind.mover(),
333                    ),
334                })
335            } else {
336                Ok(())
337            }
338        }
339    }
340}
341
342/// A `RepositoryReplication`'s **destination** credential Secret is reachable from
343/// the mover Job. The replicate Job runs in the CR's own namespace and loads the
344/// destination backend's keys via `envFrom`, which is namespace-local — a Secret
345/// in another namespace can never be read. `RepositoryReplication` deliberately has
346/// no `credentialProjection`, so an out-of-namespace destination `auth.secretRef`
347/// is a dead reference the Job would hang on (`CreateContainerConfigError`).
348/// Reject it at admission with an actionable message instead. An absent
349/// `namespace` means "same namespace as the CR" and is always legal; a workload-
350/// identity or filesystem destination carries no auth Secret and is unaffected.
351/// `cr_namespace` is the replication CR's own namespace.
352pub fn validate_replication_destination_secret_namespace(
353    destination: &crate::backend::Backend,
354    cr_namespace: &str,
355) -> ValidationResult {
356    let Some(secret_ref) = crate::creds::backend_auth_secret_ref(destination) else {
357        return Ok(());
358    };
359    match secret_ref.namespace.as_deref() {
360        Some(ns) if ns != cr_namespace => Err(ValidationError::InvalidFieldValue {
361            field: "destination backend auth.secretRef.namespace".to_string(),
362            reason: format!(
363                "Secret {name:?} is in namespace {ns:?}, but the replication Job runs in \
364                 {cr_namespace:?} and can only read Secrets there. Fix: put the destination \
365                 Secret in {cr_namespace:?}",
366                name = secret_ref.name,
367            ),
368        }),
369        _ => Ok(()),
370    }
371}