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 alphanumerics, '-' or '.', \
27                 starting and ending with an alphanumeric, 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}: with disableTls (kopia --disable-tls) there is no TLS \
88                 handshake at all, so the referenced CA bundle can never be \
89                 consulted — remove disableTls to verify with the CA bundle, or \
90                 remove caBundleRef for plain HTTP"
91            ),
92        });
93    }
94    match ca.config_map_name.as_deref() {
95        None | Some("") => {
96            return Err(ValidationError::InvalidFieldValue {
97                field: format!("{context} tls.caBundleRef.configMapName"),
98                reason: "the caBundleRef names no ConfigMap, so there is nothing to \
99                         resolve the CA bundle from — set configMapName to the \
100                         ConfigMap holding the PEM CA bundle, or remove the \
101                         caBundleRef block"
102                    .to_string(),
103            });
104        }
105        Some(name) => {
106            validate_dns1123_name(name, &format!("{context} tls.caBundleRef.configMapName"))?;
107        }
108    }
109    if let Some(key) = &ca.key
110        && key.trim().is_empty()
111    {
112        return Err(ValidationError::InvalidFieldValue {
113            field: format!("{context} tls.caBundleRef.key"),
114            reason: format!(
115                "is blank ({key:?}) and can never match a ConfigMap key — set it to \
116                 the key holding the PEM CA bundle, or omit it to use the default \
117                 \"ca.crt\""
118            ),
119        });
120    }
121    Ok(())
122}
123
124/// Validate backend *content* the structural schema can't express: the
125/// inline-NFS volume on a `Filesystem` backend, the `secretRef` XOR
126/// `workloadIdentity` rule on the cloud-IAM backends, Azure's
127/// workload-identity prerequisites, and the S3 `tls` block's consistency.
128/// Exhaustive `match` so a new `Backend` variant must be considered here
129/// before it compiles.
130pub fn validate_backend(backend: &crate::backend::Backend) -> ValidationResult {
131    use crate::backend::{Backend, RepoVolume};
132    match backend {
133        Backend::Filesystem(fs) => match &fs.volume {
134            Some(RepoVolume::Nfs(nfs)) => validate_nfs_volume(nfs, "filesystem repo"),
135            Some(RepoVolume::Pvc(_)) | None => Ok(()),
136        },
137        Backend::S3(s) => {
138            if let Some(auth) = &s.auth {
139                validate_backend_auth(auth, "s3 backend")?;
140            }
141            if let Some(tls) = &s.tls {
142                validate_backend_tls(tls, "s3 backend")?;
143            }
144            Ok(())
145        }
146        Backend::Azure(a) => match &a.auth {
147            Some(auth) => {
148                validate_backend_auth(auth, "azure backend")?;
149                // kopia's `--storage-account` is a required flag, and with
150                // workload identity there is no Secret to deliver it via the
151                // AZURE_STORAGE_ACCOUNT env var (the azure-workload-identity
152                // webhook injects only tenant/client/token-file). It must be in
153                // the spec, or every mover run fails at kopia flag parsing.
154                if auth.workload_identity.is_some() && a.storage_account.is_none() {
155                    return Err(ValidationError::InvalidFieldValue {
156                        field: "azure backend storageAccount".to_string(),
157                        reason: "required with auth.workloadIdentity: the \
158                                 azure-workload-identity webhook injects the tenant, \
159                                 client id, and federated token, but not the storage \
160                                 account — set spec.backend.azure.storageAccount"
161                            .to_string(),
162                    });
163                }
164                Ok(())
165            }
166            None => Ok(()),
167        },
168        Backend::Gcs(g) => match &g.auth {
169            Some(auth) => validate_backend_auth(auth, "gcs backend"),
170            None => Ok(()),
171        },
172        Backend::Rclone(r) => {
173            // kopia's `--rclone-startup-timeout` takes a Go duration; reject a
174            // malformed value at admission instead of failing every connect.
175            if let Some(t) = &r.startup_timeout
176                && crate::duration::parse_go_duration(t).is_none()
177            {
178                return Err(ValidationError::InvalidFieldValue {
179                    field: "rclone backend startupTimeout".to_string(),
180                    reason: format!("must be a Go duration like \"30s\" or \"2m\" (got {t:?})"),
181                });
182            }
183            Ok(())
184        }
185        Backend::Gdrive(g) => {
186            if g.folder_id.trim().is_empty() {
187                return Err(ValidationError::MissingRequiredField {
188                    field: "gdrive backend folderId".to_string(),
189                });
190            }
191            Ok(())
192        }
193        Backend::B2(_) | Backend::Sftp(_) | Backend::WebDav(_) => Ok(()),
194    }
195}
196
197/// WHICH one-pod credential pairing [`validate_replication_auth`] is judging.
198///
199/// The rule itself is identical everywhere a single mover pod carries two
200/// backends' credentials, so exactly one implementation decides it. Only the
201/// FIELD PATHS and the prose differ, and they differ enough to matter: a
202/// repository bootstrap rejection that says "destination backend auth" and
203/// blames "the replication mover" names a field the author never wrote and a
204/// mover that never ran. An enum rather than free-form strings so a new pairing
205/// cannot be added without deciding what its rejection says.
206#[derive(Debug, Clone, Copy, PartialEq, Eq)]
207pub enum AuthPairKind {
208    /// A `RepositoryReplication` / `SnapshotReplication` source→destination
209    /// pair, carried by one replication mover Job.
210    Replication,
211    /// A repository's own `spec.backend` paired with its `spec.seed` source,
212    /// carried by one seeding Job at bootstrap. Note the argument order this
213    /// implies: the `source` argument is the repository being created and the
214    /// `destination` argument is the seed it reads FROM.
215    Seed,
216}
217
218impl AuthPairKind {
219    /// Field path for the both-federated, disagreeing-ServiceAccount rejection.
220    fn service_account_field(self) -> &'static str {
221        match self {
222            Self::Replication => "destination auth.workloadIdentity.serviceAccountName",
223            Self::Seed => "seed.from.backend auth.workloadIdentity.serviceAccountName",
224        }
225    }
226
227    /// Field path for the static/workload-identity mix rejection.
228    fn auth_field(self) -> &'static str {
229        match self {
230            Self::Replication => "destination backend auth",
231            Self::Seed => "seed.from.backend auth",
232        }
233    }
234
235    /// The single pod that carries both credential sets, as a noun phrase (also
236    /// used possessively, so it must read correctly with a trailing `'s`).
237    fn mover(self) -> &'static str {
238        match self {
239            Self::Replication => "the replication mover",
240            Self::Seed => "the seeding mover",
241        }
242    }
243
244    /// How to name the `source` argument's side in prose.
245    fn source_label(self) -> &'static str {
246        match self {
247            Self::Replication => "the source repository",
248            Self::Seed => "this repository",
249        }
250    }
251
252    /// How to name the `destination` argument's side in prose.
253    fn destination_label(self) -> &'static str {
254        match self {
255            Self::Replication => "the destination",
256            Self::Seed => "the seed source",
257        }
258    }
259
260    /// The pairing itself, for "a same-kind {} pair cannot mix ...".
261    fn pair_label(self) -> &'static str {
262        match self {
263            Self::Replication => "source/destination",
264            Self::Seed => "repository/seed-source",
265        }
266    }
267}
268
269/// A `RepositoryReplication`'s source/destination auth pair is safe to run in
270/// **one** mover pod. The replicate pod's environment carries the static side's
271/// credential Secret (`envFrom`); for a same-kind S3 or Azure pair where exactly
272/// one side uses workload identity, the workload-identity side's credential
273/// chain reads those same env vars (minio-go's `EnvAWS`; kopia's env-bound azure
274/// flags) and would silently authenticate as the *other* side — wrong identity,
275/// plausibly wrong permissions, no error. Rejected at admission instead. GCS
276/// mixed pairs are safe (the static side's key travels as a `--credentials-file`
277/// path, not ambient env). Both-workload-identity pairs must name the same
278/// ServiceAccount — a pod runs as exactly one.
279///
280/// A repository `spec.seed` reuses this VERDICT unchanged — one seeding pod
281/// carries both credential sets for exactly the same reason — and passes
282/// [`AuthPairKind::Seed`] so the rejection points at `spec.seed.from.backend`
283/// and speaks about the seeding Job (issue #380).
284pub fn validate_replication_auth(
285    source: &crate::backend::Backend,
286    destination: &crate::backend::Backend,
287    kind: AuthPairKind,
288) -> ValidationResult {
289    use crate::creds::{WorkloadIdentityCloud, backend_workload_identity};
290    let src_wi = backend_workload_identity(source);
291    let dst_wi = backend_workload_identity(destination);
292    match (src_wi, dst_wi) {
293        (None, None) => Ok(()),
294        (Some((a, _)), Some((b, _))) => {
295            if a.service_account_name == b.service_account_name {
296                Ok(())
297            } else {
298                Err(ValidationError::InvalidFieldValue {
299                    field: kind.service_account_field().to_string(),
300                    reason: format!(
301                        "{mover} is one pod and runs as exactly one ServiceAccount, \
302                         but {src} federates as {a:?} and {dst} as {b:?} — point \
303                         both at the same ServiceAccount (with IAM access to both \
304                         stores)",
305                        mover = kind.mover(),
306                        src = kind.source_label(),
307                        dst = kind.destination_label(),
308                        a = a.service_account_name,
309                        b = b.service_account_name,
310                    ),
311                })
312            }
313        }
314        (Some((_, wi_cloud)), None) | (None, Some((_, wi_cloud))) => {
315            let static_side = if src_wi.is_some() {
316                destination
317            } else {
318                source
319            };
320            let conflicts = match wi_cloud {
321                WorkloadIdentityCloud::S3 => {
322                    matches!(static_side, crate::backend::Backend::S3(_))
323                }
324                WorkloadIdentityCloud::Azure => {
325                    matches!(static_side, crate::backend::Backend::Azure(_))
326                }
327                // GCS static keys travel as a --credentials-file path, never
328                // ambient env, so they cannot leak into the ADC chain.
329                WorkloadIdentityCloud::Gcs => false,
330            };
331            if conflicts {
332                Err(ValidationError::InvalidFieldValue {
333                    field: kind.auth_field().to_string(),
334                    reason: format!(
335                        "a same-kind {pair} pair cannot mix workloadIdentity with a \
336                         static credential Secret: {mover}'s environment carries \
337                         the static side's keys, and the workload-identity side's \
338                         ambient credential chain would silently pick them up and \
339                         authenticate as the wrong identity — use workloadIdentity \
340                         on both sides (one ServiceAccount with IAM access to both \
341                         stores) or static Secrets on both",
342                        pair = kind.pair_label(),
343                        mover = kind.mover(),
344                    ),
345                })
346            } else {
347                Ok(())
348            }
349        }
350    }
351}
352
353/// A `RepositoryReplication`'s **destination** credential Secret is reachable from
354/// the mover Job. The replicate Job runs in the CR's own namespace and loads the
355/// destination backend's keys via `envFrom`, which is namespace-local — a Secret
356/// in another namespace can never be read. `RepositoryReplication` deliberately has
357/// no `credentialProjection`, so an out-of-namespace destination `auth.secretRef`
358/// is a dead reference the Job would hang on (`CreateContainerConfigError`).
359/// Reject it at admission with an actionable message instead. An absent
360/// `namespace` means "same namespace as the CR" and is always legal; a workload-
361/// identity or filesystem destination carries no auth Secret and is unaffected.
362/// `cr_namespace` is the replication CR's own namespace.
363pub fn validate_replication_destination_secret_namespace(
364    destination: &crate::backend::Backend,
365    cr_namespace: &str,
366) -> ValidationResult {
367    let Some(secret_ref) = crate::creds::backend_auth_secret_ref(destination) else {
368        return Ok(());
369    };
370    match secret_ref.namespace.as_deref() {
371        Some(ns) if ns != cr_namespace => Err(ValidationError::InvalidFieldValue {
372            field: "destination backend auth.secretRef.namespace".to_string(),
373            reason: format!(
374                "the replication mover Job runs in namespace {cr_namespace:?} and loads the \
375                 destination credentials via envFrom, which is namespace-local, but the Secret \
376                 {name:?} is pinned to namespace {ns:?} — the Job could never read it. \
377                 RepositoryReplication does not project credentials across namespaces; put the \
378                 destination Secret in {cr_namespace:?} (omit `namespace`, or set it to \
379                 {cr_namespace:?})",
380                name = secret_ref.name,
381            ),
382        }),
383        _ => Ok(()),
384    }
385}