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 distinct credential Secrets a mover Job for `backend` + `encryption` needs
101/// as `envFrom`, each with its resolved *source* namespace: always the
102/// encryption-password Secret, plus the backend `auth` Secret when present and
103/// differently named. Deduped by name, order-stable (password first).
104///
105/// `repo_namespace` is the referencing repository's own namespace (a namespaced
106/// `Repository`), used as the source-namespace fallback when a reference omits
107/// one; pass `None` for a cluster-scoped `ClusterRepository`, whose references
108/// pin their own namespace. This is the single source of the dedup/order contract
109/// that [`mover_creds_secrets`] (names only) is built on.
110pub fn mover_creds_secret_refs(
111    backend: &Backend,
112    enc: &Encryption,
113    repo_namespace: Option<&str>,
114) -> Vec<CredsSecretRef> {
115    let source_ns = |ns: Option<String>| ns.or_else(|| repo_namespace.map(str::to_string));
116    let mut refs = vec![CredsSecretRef {
117        name: enc.password_secret_ref.name.clone(),
118        namespace: source_ns(enc.password_secret_ref.namespace.clone()),
119    }];
120    if let Some(auth) = backend_auth_secret_ref(backend)
121        && !refs.iter().any(|r| r.name == auth.name)
122    {
123        refs.push(CredsSecretRef {
124            name: auth.name.clone(),
125            namespace: source_ns(auth.namespace.clone()),
126        });
127    }
128    refs
129}
130
131/// The distinct credential Secret names a mover Job for `backend` + `encryption`
132/// needs as `envFrom`: always the encryption-password Secret, plus the backend
133/// `auth` Secret when present and different. Deduped, order-stable (password
134/// first). The common single-secret setup (password + keys in one Secret)
135/// collapses to one entry. Names-only projection of [`mover_creds_secret_refs`].
136pub fn mover_creds_secrets(backend: &Backend, enc: &Encryption) -> Vec<String> {
137    mover_creds_secret_refs(backend, enc, None)
138        .into_iter()
139        .map(|r| r.name)
140        .collect()
141}
142
143/// Env-var prefix under which a replication mover receives the **destination**
144/// backend's credential Secret (`envFrom.prefix`). The source backend's Secret is
145/// delivered unprefixed (kopia reads the plain names at `repository connect` and
146/// persists them), so the two sides never collide even when both are the same
147/// backend family with different keys (issue #200). The mover reads these back to
148/// authenticate the `kopia repository sync-to` destination.
149///
150/// The credential env-var *names* a backend reads (`AWS_*`, …) live on
151/// [`ConnectSpec::direct_credential_env_names`](../../kopiur_kopia/client/enum.ConnectSpec.html)
152/// in the kopia crate — that is the type that knows kopia's env binding, and the
153/// replication mover builds its remap from the `ConnectSpec` it hands to `sync-to`.
154pub const DEST_ENV_PREFIX: &str = "KOPIUR_DEST_";
155
156/// Env var carrying the **destination** repository's encryption password
157/// (`KOPIA_PASSWORD`) into a snapshot-replication mover. The source password
158/// rides the plain `KOPIA_PASSWORD` (persisted into the source kopia config at
159/// connect), so the destination's must arrive under a distinct name — the mover
160/// reads this and re-exports it as `KOPIA_PASSWORD` for the destination client
161/// only. Deliberately NOT `{DEST_ENV_PREFIX}KOPIA_PASSWORD` mechanical prefixing:
162/// the controller delivers it as a single `valueFrom.secretKeyRef` env var built
163/// from the RESOLVED destination credentials (which may be a projected copy with
164/// a different Secret name), not via an `envFrom` prefix remap.
165pub const DEST_KOPIA_PASSWORD_ENV: &str = "KOPIUR_DEST_KOPIA_PASSWORD";
166
167/// Env-var prefix under which a **seeding** bootstrap mover receives the seed
168/// SOURCE's credential Secret (`envFrom.prefix`), the mirror image of
169/// [`DEST_ENV_PREFIX`].
170///
171/// The local repository's own credentials arrive unprefixed (kopia reads the
172/// plain names at `repository connect` and persists them into its config), so
173/// the two sides never collide even when both are the same backend family with
174/// different keys. A distinct prefix from `KOPIUR_DEST_` matters because a
175/// single repository can carry BOTH a `spec.seed` and be a replication source:
176/// reusing one prefix would make the two overlays ambiguous.
177pub const SEED_ENV_PREFIX: &str = "KOPIUR_SEED_";
178
179/// Env var carrying the seed SOURCE repository's encryption password into a
180/// seeding bootstrap mover, the mirror image of [`DEST_KOPIA_PASSWORD_ENV`].
181///
182/// **Migrate mode only.** A blob-mode seed (`kopia repository sync-to`) copies
183/// storage verbatim and the copy therefore keeps the mirror's format and
184/// password — there is exactly one password in play, the local repository's own
185/// `KOPIA_PASSWORD`, and this var is unset. Migrate mode reads two genuinely
186/// different repositories, so the source's password must arrive under a name
187/// that cannot be mistaken for the destination's.
188///
189/// Deliberately NOT `{SEED_ENV_PREFIX}KOPIA_PASSWORD` by mechanical prefixing:
190/// like `KOPIUR_DEST_KOPIA_PASSWORD`, the controller delivers it as a single
191/// `valueFrom.secretKeyRef` built from the RESOLVED source credentials (which
192/// may be a projected copy under a different Secret name), not via an `envFrom`
193/// prefix remap.
194pub const SEED_KOPIA_PASSWORD_ENV: &str = "KOPIUR_SEED_KOPIA_PASSWORD";
195
196#[cfg(test)]
197mod dest_env_tests {
198    use super::*;
199
200    #[test]
201    fn dest_env_prefix_is_stable() {
202        assert_eq!(DEST_ENV_PREFIX, "KOPIUR_DEST_");
203    }
204
205    /// The controller writes this env var into replication mover Jobs and the
206    /// mover reads it back by name — a rename would silently break every
207    /// in-flight Job across a skewed upgrade, so the literal is pinned exactly
208    /// like [`DEST_ENV_PREFIX`]'s.
209    #[test]
210    fn dest_kopia_password_env_is_stable() {
211        assert_eq!(DEST_KOPIA_PASSWORD_ENV, "KOPIUR_DEST_KOPIA_PASSWORD");
212    }
213
214    /// Same wire-contract pin as the destination pair: the controller writes
215    /// these into seeding bootstrap Jobs and the mover reads them back by name,
216    /// so a rename would silently break every in-flight Job across a skewed
217    /// upgrade.
218    #[test]
219    fn seed_env_names_are_stable() {
220        assert_eq!(SEED_ENV_PREFIX, "KOPIUR_SEED_");
221        assert_eq!(SEED_KOPIA_PASSWORD_ENV, "KOPIUR_SEED_KOPIA_PASSWORD");
222    }
223
224    /// The seed and destination overlays must never collide: one repository can
225    /// be both a seed target and a replication source, and a shared prefix
226    /// would make the two `envFrom` remaps ambiguous.
227    #[test]
228    fn seed_and_dest_prefixes_are_disjoint() {
229        assert_ne!(SEED_ENV_PREFIX, DEST_ENV_PREFIX);
230        assert!(!SEED_ENV_PREFIX.starts_with(DEST_ENV_PREFIX));
231        assert!(!DEST_ENV_PREFIX.starts_with(SEED_ENV_PREFIX));
232    }
233}