Skip to main content

kopiur_api/common/
privileged.rs

1//! What `mover.privilegedMode: true` actually grants a mover (#552).
2//!
3//! The hardened base layer ([`super::hardened_security_context`]) drops **every** Linux
4//! capability, and the field-wise merge keeps that `drop: [ALL]` unless a layer sets its
5//! own `capabilities`. So a mover running as `runAsUser: 0` with nothing else is root
6//! *without* `CAP_DAC_OVERRIDE`: the kernel applies normal owner/group/other permission
7//! checks to it, and a `0660` file owned by another uid/gid is unreadable. Before #552,
8//! `privilegedMode` only tripped the namespace gate and granted nothing on top.
9//!
10//! This module is the single, pure definition of the grant. It runs on the RESOLVED
11//! mover context (after the `hardened ⊂ moverDefaults ⊂ inherited ⊂ explicit` merge),
12//! so the webhook and the controller compute it identically.
13//!
14//! # The contract
15//!
16//! `privilegedMode` **never changes the UID**. Who the mover runs as decides who owns
17//! the repository blobs it writes; silently switching an existing `65532` mover to root
18//! would leave root-owned blobs on a filesystem repository that every other (non-root)
19//! mover then cannot read, and breaks outright on an NFS export with `root_squash`.
20//!
21//! When `privilegedMode` is `true`:
22//!
23//! - If the effective (merged container/pod) `runAsUser` is **`0`**, the capabilities the
24//!   [`MoverOperation`] needs are **added** on top of `drop: [ALL]`, unioned with any
25//!   `capabilities.add` a layer already set (never removing one).
26//! - Otherwise — a pinned non-zero UID, or no UID at all (the image's unprivileged
27//!   `65532`) — it is inert: Kubernetes sets no ambient capabilities, so added
28//!   capabilities would do nothing for a non-root process. Nothing is granted and the
29//!   case is reported ([`PrivilegedModeEffect::InertNonRootUid`] /
30//!   [`PrivilegedModeEffect::InertUnsetUid`]) so the webhook and the controller can tell
31//!   the user to set `runAsUser: 0`.
32//!
33//! Every capability granted here is in the Pod Security Standards **baseline** allow
34//! list ("Adding additional capabilities beyond those listed below must be disallowed:
35//! AUDIT_WRITE, CHOWN, DAC_OVERRIDE, FOWNER, FSETID, KILL, MKNOD, NET_BIND_SERVICE,
36//! SETFCAP, SETGID, SETPCAP, SETUID, SYS_CHROOT" —
37//! <https://kubernetes.io/docs/concepts/security/pod-security-standards/#baseline>).
38//! That is why a backup gets `DAC_OVERRIDE` and not the narrower-sounding
39//! `DAC_READ_SEARCH`: the latter is NOT on the baseline list, so it would make a
40//! privileged mover fail admission in a `baseline`-enforced namespace. (A root mover
41//! already violates `restricted`, which requires `runAsNonRoot`.)
42
43use k8s_openapi::api::core::v1::{Capabilities, PodSecurityContext, SecurityContext};
44
45use super::effective_run_as_user;
46
47/// `CAP_DAC_OVERRIDE`: bypass file read/write/execute permission checks.
48pub const CAP_DAC_OVERRIDE: &str = "DAC_OVERRIDE";
49/// `CAP_DAC_READ_SEARCH`: bypass file read and directory search checks. Never granted
50/// by kopiur (not PSS-baseline), but honored when a user adds it themselves.
51pub const CAP_DAC_READ_SEARCH: &str = "DAC_READ_SEARCH";
52/// `CAP_CHOWN`: change a file's owner and group to any uid/gid.
53pub const CAP_CHOWN: &str = "CHOWN";
54/// `CAP_FOWNER`: bypass owner-only checks (chmod, utimes, …) on files it doesn't own.
55pub const CAP_FOWNER: &str = "FOWNER";
56/// `CAP_FSETID`: keep setuid/setgid bits when changing a file's owner or mode.
57pub const CAP_FSETID: &str = "FSETID";
58
59/// What a mover Job does, for deciding which capabilities `privilegedMode` grants.
60/// Exhaustive on purpose: a new mover operation does not compile until it states what
61/// privilege it needs.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum MoverOperation {
64    /// Snapshot a source volume (reads arbitrary files).
65    Backup,
66    /// Restore into a target volume (writes files and reproduces their owner and mode).
67    Restore,
68    /// Repository maintenance.
69    Maintenance,
70    /// Snapshot verification.
71    Verify,
72    /// Repository create/connect.
73    Bootstrap,
74    /// Repository-to-repository replication.
75    RepositoryReplicate,
76    /// Snapshot replication.
77    SnapshotReplicate,
78    /// Batched kopia snapshot deletion.
79    SnapshotDelete,
80    /// Pin/unpin a kopia snapshot.
81    SnapshotPin,
82    /// Browse a snapshot's contents.
83    Browse,
84}
85
86impl MoverOperation {
87    /// The capabilities `privilegedMode: true` adds for this operation. Empty means
88    /// `privilegedMode` grants nothing beyond tripping the namespace gate.
89    pub fn privileged_capabilities(self) -> &'static [&'static str] {
90        match self {
91            // Read every file in the source regardless of owner and mode.
92            MoverOperation::Backup => &[CAP_DAC_OVERRIDE],
93            // Write into directories it doesn't own (DAC_OVERRIDE), give each file its
94            // recorded owner (CHOWN), then set mode/times on a file it no longer owns
95            // (FOWNER) without the kernel clearing setuid/setgid bits (FSETID).
96            MoverOperation::Restore => &[CAP_CHOWN, CAP_DAC_OVERRIDE, CAP_FOWNER, CAP_FSETID],
97            // These only talk to the repository (or the kopia cache the operator owns
98            // through fsGroup). None of them mounts a user source/target volume — browse
99            // reads snapshot content through kopia, not from a PVC — so there is no file
100            // owned by a foreign uid for a capability to unlock.
101            MoverOperation::Maintenance
102            | MoverOperation::Verify
103            | MoverOperation::Bootstrap
104            | MoverOperation::RepositoryReplicate
105            | MoverOperation::SnapshotReplicate
106            | MoverOperation::SnapshotDelete
107            | MoverOperation::SnapshotPin
108            | MoverOperation::Browse => &[],
109        }
110    }
111}
112
113/// What [`apply_privileged_mode`] did to a resolved mover context.
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub enum PrivilegedModeEffect {
116    /// `privilegedMode` is not `true`; the context is unchanged.
117    NotRequested,
118    /// `privilegedMode` is `true` but the operation needs no capability; unchanged.
119    NothingToGrant,
120    /// The mover runs as root, so the operation's capabilities were added.
121    Granted,
122    /// A layer pins a non-zero UID, so capabilities would be inert; nothing was granted.
123    InertNonRootUid {
124        /// The pinned effective UID.
125        uid: i64,
126    },
127    /// No layer pins a UID, so the mover runs as the image's non-root user and
128    /// capabilities would be inert; nothing was granted and the UID is left alone.
129    InertUnsetUid,
130}
131
132impl PrivilegedModeEffect {
133    /// A short user-facing message for the inert cases (`None` otherwise).
134    pub fn warning(&self) -> Option<String> {
135        match self {
136            PrivilegedModeEffect::InertNonRootUid { uid } => Some(format!(
137                "privilegedMode grants capabilities only to a root mover; this one runs as \
138                 UID {uid}. Fix: set securityContext.runAsUser: 0."
139            )),
140            PrivilegedModeEffect::InertUnsetUid => Some(
141                "privilegedMode grants capabilities only to a root mover. Fix: set \
142                 securityContext.runAsUser: 0."
143                    .to_string(),
144            ),
145            PrivilegedModeEffect::NotRequested
146            | PrivilegedModeEffect::NothingToGrant
147            | PrivilegedModeEffect::Granted => None,
148        }
149    }
150}
151
152/// Decide what `privilegedMode` does to a resolved `(container, pod)` context without
153/// changing it. Pure; the decision half of [`apply_privileged_mode`].
154pub fn privileged_mode_effect(
155    sc: &SecurityContext,
156    psc: Option<&PodSecurityContext>,
157    privileged_mode: Option<bool>,
158    op: MoverOperation,
159) -> PrivilegedModeEffect {
160    if privileged_mode != Some(true) {
161        return PrivilegedModeEffect::NotRequested;
162    }
163    if op.privileged_capabilities().is_empty() {
164        return PrivilegedModeEffect::NothingToGrant;
165    }
166    match effective_run_as_user(Some(sc), psc) {
167        None => PrivilegedModeEffect::InertUnsetUid,
168        Some(0) => PrivilegedModeEffect::Granted,
169        Some(uid) => PrivilegedModeEffect::InertNonRootUid { uid },
170    }
171}
172
173/// Apply `privilegedMode` to a resolved mover context (see the module docs for the
174/// contract). Pure and idempotent. Only ever touches `capabilities` — never the UID.
175pub fn apply_privileged_mode(
176    mut sc: SecurityContext,
177    psc: Option<PodSecurityContext>,
178    privileged_mode: Option<bool>,
179    op: MoverOperation,
180) -> (
181    SecurityContext,
182    Option<PodSecurityContext>,
183    PrivilegedModeEffect,
184) {
185    let effect = privileged_mode_effect(&sc, psc.as_ref(), privileged_mode, op);
186    match &effect {
187        PrivilegedModeEffect::Granted => {
188            sc.capabilities = Some(add_capabilities(
189                sc.capabilities.take(),
190                op.privileged_capabilities(),
191            ));
192        }
193        PrivilegedModeEffect::NotRequested
194        | PrivilegedModeEffect::NothingToGrant
195        | PrivilegedModeEffect::InertNonRootUid { .. }
196        | PrivilegedModeEffect::InertUnsetUid => {}
197    }
198    (sc, psc, effect)
199}
200
201/// Union `grant` into `caps.add`, keeping `drop` and every capability already added.
202/// A capability already present (with or without the `CAP_` prefix, any case) is not
203/// duplicated.
204fn add_capabilities(caps: Option<Capabilities>, grant: &[&str]) -> Capabilities {
205    let mut caps = caps.unwrap_or_default();
206    let mut add = caps.add.take().unwrap_or_default();
207    for cap in grant {
208        if !add.iter().any(|have| same_capability(have, cap)) {
209            add.push((*cap).to_string());
210        }
211    }
212    caps.add = Some(add);
213    caps
214}
215
216/// Whether two capability names refer to the same capability. Kubernetes accepts both
217/// `DAC_OVERRIDE` and `CAP_DAC_OVERRIDE`.
218pub fn same_capability(a: &str, b: &str) -> bool {
219    fn norm(s: &str) -> String {
220        let up = s.trim().to_ascii_uppercase();
221        up.strip_prefix("CAP_").map(str::to_string).unwrap_or(up)
222    }
223    norm(a) == norm(b)
224}
225
226/// Whether a container context can read any file regardless of owner and mode: it is
227/// `privileged: true`, or it adds `DAC_OVERRIDE` or `DAC_READ_SEARCH`. Only meaningful
228/// for a root process (capabilities are inert for any other UID).
229pub fn bypasses_read_permission_checks(sc: &SecurityContext) -> bool {
230    sc.privileged == Some(true)
231        || sc
232            .capabilities
233            .as_ref()
234            .and_then(|c| c.add.as_ref())
235            .is_some_and(|add| {
236                add.iter().any(|c| {
237                    same_capability(c, CAP_DAC_OVERRIDE) || same_capability(c, CAP_DAC_READ_SEARCH)
238                })
239            })
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use crate::common::{ResolvedMover, resolve_mover};
246
247    fn root_sc() -> SecurityContext {
248        SecurityContext {
249            run_as_user: Some(0),
250            run_as_non_root: Some(false),
251            ..Default::default()
252        }
253    }
254
255    fn resolved(
256        sc: Option<&SecurityContext>,
257        privileged: Option<bool>,
258        op: MoverOperation,
259    ) -> ResolvedMover {
260        resolve_mover(None, sc, None, None, None, None).with_privileged_mode(privileged, op)
261    }
262
263    fn adds(m: &ResolvedMover) -> Vec<String> {
264        m.security_context
265            .capabilities
266            .as_ref()
267            .and_then(|c| c.add.clone())
268            .unwrap_or_default()
269    }
270
271    fn drops(m: &ResolvedMover) -> Vec<String> {
272        m.security_context
273            .capabilities
274            .as_ref()
275            .and_then(|c| c.drop.clone())
276            .unwrap_or_default()
277    }
278
279    /// #552: the reported configuration. A privileged root backup must be able to read a
280    /// 0660 file owned by someone else, which takes DAC_OVERRIDE — while still dropping
281    /// every other capability.
282    #[test]
283    fn privileged_root_backup_gets_dac_override_and_still_drops_all() {
284        let m = resolved(Some(&root_sc()), Some(true), MoverOperation::Backup);
285        assert_eq!(adds(&m), vec!["DAC_OVERRIDE"]);
286        assert_eq!(drops(&m), vec!["ALL"]);
287        assert_eq!(m.security_context.run_as_user, Some(0));
288        assert_eq!(m.security_context.run_as_non_root, Some(false));
289        assert_eq!(m.security_context.allow_privilege_escalation, Some(false));
290    }
291
292    #[test]
293    fn privileged_root_restore_gets_the_four_ownership_capabilities() {
294        let m = resolved(Some(&root_sc()), Some(true), MoverOperation::Restore);
295        assert_eq!(adds(&m), vec!["CHOWN", "DAC_OVERRIDE", "FOWNER", "FSETID"]);
296        assert_eq!(drops(&m), vec!["ALL"]);
297    }
298
299    /// Root alone stays capability-less: privilege is an explicit opt-in.
300    #[test]
301    fn non_privileged_root_gets_no_capabilities() {
302        for op in [MoverOperation::Backup, MoverOperation::Restore] {
303            for pm in [None, Some(false)] {
304                let m = resolved(Some(&root_sc()), pm, op);
305                assert!(adds(&m).is_empty(), "{op:?} {pm:?}");
306                assert_eq!(drops(&m), vec!["ALL"]);
307            }
308        }
309    }
310
311    #[test]
312    fn user_added_capabilities_are_kept_and_unioned() {
313        let mut sc = root_sc();
314        sc.capabilities = Some(Capabilities {
315            add: Some(vec!["SETUID".into(), "cap_dac_override".into()]),
316            drop: None,
317        });
318        let m = resolved(Some(&sc), Some(true), MoverOperation::Restore);
319        assert_eq!(
320            adds(&m),
321            vec!["SETUID", "cap_dac_override", "CHOWN", "FOWNER", "FSETID"],
322            "nothing the user asked for is removed and DAC_OVERRIDE is not duplicated"
323        );
324        assert_eq!(
325            drops(&m),
326            vec!["ALL"],
327            "the hardened drop:[ALL] survives an add-only override"
328        );
329    }
330
331    /// No layer pins a UID: privilegedMode never picks one (it never changes who writes
332    /// repository blobs), so the mover keeps the image's non-root user and is granted
333    /// nothing — and the user is told.
334    #[test]
335    fn privileged_with_no_pinned_uid_keeps_the_uid_grants_nothing_and_warns() {
336        for op in [MoverOperation::Backup, MoverOperation::Restore] {
337            let base = resolve_mover(None, None, None, None, None, None);
338            let (m, effect) = base.clone().with_privileged_mode_effect(Some(true), op);
339            assert_eq!(m, base, "{op:?}: uid/runAsNonRoot/caps unchanged");
340            assert_eq!(m.security_context.run_as_user, None);
341            assert_eq!(m.security_context.run_as_non_root, Some(true));
342            assert!(adds(&m).is_empty());
343            assert_eq!(effect, PrivilegedModeEffect::InertUnsetUid);
344            let msg = effect.warning().expect("inert privilegedMode warns");
345            assert!(
346                msg.contains("root mover") && msg.contains("Fix: set securityContext.runAsUser: 0"),
347                "{msg}"
348            );
349        }
350    }
351
352    /// A pinned non-zero UID is respected, and nothing inert is added.
353    #[test]
354    fn privileged_with_a_pinned_non_root_uid_grants_nothing_and_warns() {
355        let sc = SecurityContext {
356            run_as_user: Some(1000),
357            ..Default::default()
358        };
359        let base = resolve_mover(None, Some(&sc), None, None, None, None);
360        let (out, _, effect) = apply_privileged_mode(
361            base.security_context.clone(),
362            base.pod_security_context.clone(),
363            Some(true),
364            MoverOperation::Backup,
365        );
366        assert_eq!(out, base.security_context);
367        assert_eq!(effect, PrivilegedModeEffect::InertNonRootUid { uid: 1000 });
368        let msg = effect.warning().expect("inert privilegedMode warns");
369        assert!(msg.contains("UID 1000") && msg.contains("Fix:"), "{msg}");
370    }
371
372    /// A pod-level root UID is root too (kubelet precedence).
373    #[test]
374    fn pod_level_root_is_granted() {
375        let psc = PodSecurityContext {
376            run_as_user: Some(0),
377            ..Default::default()
378        };
379        let m = resolve_mover(None, None, Some(&psc), None, None, None)
380            .with_privileged_mode(Some(true), MoverOperation::Backup);
381        assert_eq!(adds(&m), vec!["DAC_OVERRIDE"]);
382        assert_eq!(m.security_context.run_as_user, Some(0));
383    }
384
385    #[test]
386    fn repository_only_operations_grant_nothing() {
387        for op in [
388            MoverOperation::Maintenance,
389            MoverOperation::Verify,
390            MoverOperation::Bootstrap,
391            MoverOperation::RepositoryReplicate,
392            MoverOperation::SnapshotReplicate,
393            MoverOperation::SnapshotDelete,
394            MoverOperation::SnapshotPin,
395            MoverOperation::Browse,
396        ] {
397            let base = resolve_mover(None, Some(&root_sc()), None, None, None, None);
398            let m = base.clone().with_privileged_mode(Some(true), op);
399            assert_eq!(m, base, "{op:?}");
400        }
401    }
402
403    #[test]
404    fn admission_warning_unless_the_spec_pins_root() {
405        use crate::common::MoverSpec;
406        let non_root = MoverSpec {
407            security_context: Some(SecurityContext {
408                run_as_user: Some(1000),
409                ..Default::default()
410            }),
411            privileged_mode: Some(true),
412            ..Default::default()
413        };
414        assert!(
415            non_root
416                .privileged_mode_warning(MoverOperation::Backup)
417                .is_some()
418        );
419        // Not for an operation privilegedMode grants nothing to anyway.
420        assert!(
421            non_root
422                .privileged_mode_warning(MoverOperation::Maintenance)
423                .is_none()
424        );
425        let root = MoverSpec {
426            security_context: Some(root_sc()),
427            privileged_mode: Some(true),
428            ..Default::default()
429        };
430        assert!(
431            root.privileged_mode_warning(MoverOperation::Restore)
432                .is_none()
433        );
434        let unset = MoverSpec {
435            privileged_mode: Some(true),
436            ..Default::default()
437        };
438        // No uid on the object itself: the repository's moverDefaults may still pin
439        // root, which admission cannot see, so it stays silent rather than warn on a
440        // config that may be correct. The controller's PrivilegedModeInert Event,
441        // which sees the merged context, reports the truly-inert case.
442        assert!(
443            unset
444                .privileged_mode_warning(MoverOperation::Backup)
445                .is_none(),
446            "admission must not guess about a uid moverDefaults may pin"
447        );
448        let off = MoverSpec {
449            privileged_mode: Some(false),
450            ..non_root.clone()
451        };
452        assert!(
453            off.privileged_mode_warning(MoverOperation::Backup)
454                .is_none()
455        );
456    }
457
458    #[test]
459    fn apply_is_idempotent() {
460        for op in [MoverOperation::Backup, MoverOperation::Restore] {
461            let once = resolved(Some(&root_sc()), Some(true), op);
462            let twice = once.clone().with_privileged_mode(Some(true), op);
463            assert_eq!(once, twice, "{op:?}");
464        }
465    }
466
467    /// Every granted capability is on the PSS baseline allow list.
468    #[test]
469    fn every_grant_is_pss_baseline() {
470        const BASELINE: &[&str] = &[
471            "AUDIT_WRITE",
472            "CHOWN",
473            "DAC_OVERRIDE",
474            "FOWNER",
475            "FSETID",
476            "KILL",
477            "MKNOD",
478            "NET_BIND_SERVICE",
479            "SETFCAP",
480            "SETGID",
481            "SETPCAP",
482            "SETUID",
483            "SYS_CHROOT",
484        ];
485        for op in [MoverOperation::Backup, MoverOperation::Restore] {
486            for cap in op.privileged_capabilities() {
487                assert!(BASELINE.contains(cap), "{cap} is not PSS baseline");
488            }
489        }
490    }
491
492    #[test]
493    fn read_bypass_detection() {
494        let mut sc = root_sc();
495        assert!(!bypasses_read_permission_checks(&sc));
496        for cap in ["DAC_OVERRIDE", "CAP_DAC_READ_SEARCH", "dac_override"] {
497            sc.capabilities = Some(Capabilities {
498                add: Some(vec![cap.into()]),
499                drop: Some(vec!["ALL".into()]),
500            });
501            assert!(bypasses_read_permission_checks(&sc), "{cap}");
502        }
503        sc.capabilities = Some(Capabilities {
504            add: Some(vec!["CHOWN".into()]),
505            drop: None,
506        });
507        assert!(!bypasses_read_permission_checks(&sc));
508        let privileged = SecurityContext {
509            privileged: Some(true),
510            ..Default::default()
511        };
512        assert!(bypasses_read_permission_checks(&privileged));
513    }
514}