Skip to main content

kopiur_api/
creds.rs

1//! Pure credential/volume metadata over the CRD types: which Secrets a
2//! repository's mover needs. Shared by the controller (envFrom projection,
3//! referent watches) and external tooling (`kubectl kopiur doctor`), so the
4//! "what credentials does this backend reference" answer cannot fork.
5
6use crate::backend::{Backend, WorkloadIdentity};
7use crate::common::Encryption;
8
9/// The backend credentials Secret name for an object-store backend, if any.
10///
11/// Exhaustive over [`Backend`] (ADR §5.5): a new backend cannot compile until its
12/// credential source is decided here. Object stores read keys (e.g.
13/// `AWS_ACCESS_KEY_ID`) from `auth.secretRef`; Rclone reads its config from
14/// `configSecretRef`; Filesystem has no backend credentials. This Secret is
15/// mounted into the mover Job alongside the encryption-password Secret so kopia
16/// can reach the store (the in-process filesystem path never needs it).
17pub fn backend_auth_secret_ref(backend: &Backend) -> Option<&crate::common::SecretRef> {
18    match backend {
19        Backend::S3(b) => b.auth.as_ref().and_then(|a| a.secret_ref.as_ref()),
20        Backend::Azure(b) => b.auth.as_ref().and_then(|a| a.secret_ref.as_ref()),
21        Backend::Gcs(b) => b.auth.as_ref().and_then(|a| a.secret_ref.as_ref()),
22        Backend::B2(b) => b.auth.as_ref().and_then(|a| a.secret_ref.as_ref()),
23        Backend::Sftp(b) => b.auth.as_ref().and_then(|a| a.secret_ref.as_ref()),
24        Backend::WebDav(b) => b.auth.as_ref().and_then(|a| a.secret_ref.as_ref()),
25        Backend::Rclone(b) => b.config_secret_ref.as_ref(),
26        Backend::Gdrive(b) => b.credentials_secret_ref.as_ref(),
27        Backend::Filesystem(_) => None,
28    }
29}
30
31/// Which cloud IAM plane a workload-identity backend federates with. Drives the
32/// cloud-specific mover wiring (the Azure pod label; docs/messages naming the
33/// right SA annotation).
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum WorkloadIdentityCloud {
36    /// AWS: IRSA (web-identity token) or EKS Pod Identity via the minio-go
37    /// credential chain.
38    S3,
39    /// Azure Workload Identity: the azure-workload-identity webhook injects
40    /// `AZURE_TENANT_ID`/`AZURE_CLIENT_ID`/`AZURE_FEDERATED_TOKEN_FILE` into
41    /// pods carrying the opt-in label and running as the federated SA.
42    Azure,
43    /// GKE Workload Identity: ambient ADC via the GKE metadata server.
44    Gcs,
45}
46
47/// The backend's workload-identity binding, if any, with its cloud plane.
48///
49/// Exhaustive over [`Backend`] (ADR §5.5): only S3/Azure/GCS can carry one —
50/// the other backends' auth types make it unrepresentable, and a new backend
51/// cannot compile until its arm is decided here.
52pub fn backend_workload_identity(
53    backend: &Backend,
54) -> Option<(&WorkloadIdentity, WorkloadIdentityCloud)> {
55    match backend {
56        Backend::S3(b) => b
57            .auth
58            .as_ref()
59            .and_then(|a| a.workload_identity.as_ref())
60            .map(|wi| (wi, WorkloadIdentityCloud::S3)),
61        Backend::Azure(b) => b
62            .auth
63            .as_ref()
64            .and_then(|a| a.workload_identity.as_ref())
65            .map(|wi| (wi, WorkloadIdentityCloud::Azure)),
66        Backend::Gcs(b) => b
67            .auth
68            .as_ref()
69            .and_then(|a| a.workload_identity.as_ref())
70            .map(|wi| (wi, WorkloadIdentityCloud::Gcs)),
71        Backend::B2(_)
72        | Backend::Sftp(_)
73        | Backend::WebDav(_)
74        | Backend::Rclone(_)
75        | Backend::Gdrive(_)
76        | Backend::Filesystem(_) => None,
77    }
78}
79
80/// A credential Secret a mover Job needs, with the namespace it is sourced from.
81/// `namespace` is the resolved *source* namespace (where the operator reads the
82/// Secret when projecting), not the Job's namespace. `None` only when neither the
83/// reference nor the repository carries one — which projection treats as an
84/// actionable error (a `ClusterRepository` reference must pin a namespace).
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct CredsSecretRef {
87    /// Name of the credential `Secret`.
88    pub name: String,
89    /// Resolved source namespace, if known.
90    pub namespace: Option<String>,
91}
92
93/// The highest per-repository credential-Secret index [`mover_creds_secret_refs`]
94/// can ever yield: the encryption-password Secret (idx 0) plus, only when it is
95/// differently named, the backend's auth Secret (idx 1). Every consumer that
96/// derives per-index resource names (projected copies, server mirrors) bounds
97/// its reap walks with this.
98pub const MAX_CREDS_IDX: usize = 1;
99
100/// The name of the operator's copy of the `idx`-th credential Secret for the
101/// browse session Job `job`: `{job}-creds-{idx}`.
102///
103/// One function for both sides, because they never talk: the session Job loads
104/// this name via `envFrom` before the copy exists, and the operator, seeing the
105/// Job, writes the copy under the same name. A session Job name is at most 47
106/// characters, so this always fits a DNS label without capping.
107pub fn browse_session_copy_name(job: &str, idx: usize) -> String {
108    format!("{job}-creds-{idx}")
109}
110
111/// The distinct credential Secrets a mover Job for `backend` + `encryption` needs
112/// as `envFrom`, each with its resolved *source* namespace: always the
113/// encryption-password Secret, plus the backend `auth` Secret when present and
114/// differently named. Deduped by name, order-stable (password first).
115///
116/// `repo_namespace` is the referencing repository's own namespace (a namespaced
117/// `Repository`), used as the source-namespace fallback when a reference omits
118/// one; pass `None` for a cluster-scoped `ClusterRepository`, whose references
119/// pin their own namespace. This is the single source of the dedup/order contract
120/// that [`mover_creds_secrets`] (names only) is built on.
121pub fn mover_creds_secret_refs(
122    backend: &Backend,
123    enc: &Encryption,
124    repo_namespace: Option<&str>,
125) -> Vec<CredsSecretRef> {
126    let source_ns = |ns: Option<String>| ns.or_else(|| repo_namespace.map(str::to_string));
127    let mut refs = vec![CredsSecretRef {
128        name: enc.password_secret_ref.name.clone(),
129        namespace: source_ns(enc.password_secret_ref.namespace.clone()),
130    }];
131    if let Some(auth) = backend_auth_secret_ref(backend)
132        && !refs.iter().any(|r| r.name == auth.name)
133    {
134        refs.push(CredsSecretRef {
135            name: auth.name.clone(),
136            namespace: source_ns(auth.namespace.clone()),
137        });
138    }
139    refs
140}
141
142/// The distinct credential Secret names a mover Job for `backend` + `encryption`
143/// needs as `envFrom`: always the encryption-password Secret, plus the backend
144/// `auth` Secret when present and different. Deduped, order-stable (password
145/// first). The common single-secret setup (password + keys in one Secret)
146/// collapses to one entry. Names-only projection of [`mover_creds_secret_refs`].
147pub fn mover_creds_secrets(backend: &Backend, enc: &Encryption) -> Vec<String> {
148    mover_creds_secret_refs(backend, enc, None)
149        .into_iter()
150        .map(|r| r.name)
151        .collect()
152}
153
154/// Env-var prefix under which a replication mover receives the **destination**
155/// backend's credential Secret (`envFrom.prefix`). The source backend's Secret is
156/// delivered unprefixed (kopia reads the plain names at `repository connect` and
157/// persists them), so the two sides never collide even when both are the same
158/// backend family with different keys (issue #200). The mover reads these back to
159/// authenticate the `kopia repository sync-to` destination.
160///
161/// The credential env-var *names* a backend reads (`AWS_*`, …) live on
162/// [`ConnectSpec::direct_credential_env_names`](../../kopiur_kopia/client/enum.ConnectSpec.html)
163/// in the kopia crate — that is the type that knows kopia's env binding, and the
164/// replication mover builds its remap from the `ConnectSpec` it hands to `sync-to`.
165pub const DEST_ENV_PREFIX: &str = "KOPIUR_DEST_";
166
167/// Env var carrying the **destination** repository's encryption password
168/// (`KOPIA_PASSWORD`) into a snapshot-replication mover. The source password
169/// rides the plain `KOPIA_PASSWORD` (persisted into the source kopia config at
170/// connect), so the destination's must arrive under a distinct name — the mover
171/// reads this and re-exports it as `KOPIA_PASSWORD` for the destination client
172/// only. Deliberately NOT `{DEST_ENV_PREFIX}KOPIA_PASSWORD` mechanical prefixing:
173/// the controller delivers it as a single `valueFrom.secretKeyRef` env var built
174/// from the RESOLVED destination credentials (which may be a projected copy with
175/// a different Secret name), not via an `envFrom` prefix remap.
176pub const DEST_KOPIA_PASSWORD_ENV: &str = "KOPIUR_DEST_KOPIA_PASSWORD";
177
178/// Env-var prefix under which a **seeding** bootstrap mover receives the seed
179/// SOURCE's credential Secret (`envFrom.prefix`), the mirror image of
180/// [`DEST_ENV_PREFIX`].
181///
182/// The local repository's own credentials arrive unprefixed (kopia reads the
183/// plain names at `repository connect` and persists them into its config), so
184/// the two sides never collide even when both are the same backend family with
185/// different keys. A distinct prefix from `KOPIUR_DEST_` matters because a
186/// single repository can carry BOTH a `spec.seed` and be a replication source:
187/// reusing one prefix would make the two overlays ambiguous.
188pub const SEED_ENV_PREFIX: &str = "KOPIUR_SEED_";
189
190/// Env var carrying the seed SOURCE repository's encryption password into a
191/// seeding bootstrap mover, the mirror image of [`DEST_KOPIA_PASSWORD_ENV`].
192///
193/// **Migrate mode only.** A blob-mode seed (`kopia repository sync-to`) copies
194/// storage verbatim and the copy therefore keeps the mirror's format and
195/// password — there is exactly one password in play, the local repository's own
196/// `KOPIA_PASSWORD`, and this var is unset. Migrate mode reads two genuinely
197/// different repositories, so the source's password must arrive under a name
198/// that cannot be mistaken for the destination's.
199///
200/// Deliberately NOT `{SEED_ENV_PREFIX}KOPIA_PASSWORD` by mechanical prefixing:
201/// like `KOPIUR_DEST_KOPIA_PASSWORD`, the controller delivers it as a single
202/// `valueFrom.secretKeyRef` built from the RESOLVED source credentials (which
203/// may be a projected copy under a different Secret name), not via an `envFrom`
204/// prefix remap.
205pub const SEED_KOPIA_PASSWORD_ENV: &str = "KOPIUR_SEED_KOPIA_PASSWORD";
206
207#[cfg(test)]
208mod dest_env_tests {
209    use super::*;
210
211    #[test]
212    fn dest_env_prefix_is_stable() {
213        assert_eq!(DEST_ENV_PREFIX, "KOPIUR_DEST_");
214    }
215
216    /// The controller writes this env var into replication mover Jobs and the
217    /// mover reads it back by name — a rename would silently break every
218    /// in-flight Job across a skewed upgrade, so the literal is pinned exactly
219    /// like [`DEST_ENV_PREFIX`]'s.
220    #[test]
221    fn dest_kopia_password_env_is_stable() {
222        assert_eq!(DEST_KOPIA_PASSWORD_ENV, "KOPIUR_DEST_KOPIA_PASSWORD");
223    }
224
225    /// Same wire-contract pin as the destination pair: the controller writes
226    /// these into seeding bootstrap Jobs and the mover reads them back by name,
227    /// so a rename would silently break every in-flight Job across a skewed
228    /// upgrade.
229    #[test]
230    fn seed_env_names_are_stable() {
231        assert_eq!(SEED_ENV_PREFIX, "KOPIUR_SEED_");
232        assert_eq!(SEED_KOPIA_PASSWORD_ENV, "KOPIUR_SEED_KOPIA_PASSWORD");
233    }
234
235    /// The seed and destination overlays must never collide: one repository can
236    /// be both a seed target and a replication source, and a shared prefix
237    /// would make the two `envFrom` remaps ambiguous.
238    #[test]
239    fn seed_and_dest_prefixes_are_disjoint() {
240        assert_ne!(SEED_ENV_PREFIX, DEST_ENV_PREFIX);
241        assert!(!SEED_ENV_PREFIX.starts_with(DEST_ENV_PREFIX));
242        assert!(!DEST_ENV_PREFIX.starts_with(SEED_ENV_PREFIX));
243    }
244}