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}