Skip to main content

kopiur_api/
invariants.rs

1//! Security-context invariants for the resolved mover pod.
2//!
3//! The mover's effective container + pod security contexts are produced by the field-wise
4//! merge `hardened ⊂ moverDefaults ⊂ recipe`/`inheritSecurityContextFrom`
5//! ([`crate::common::resolve_mover`]). Because each field is taken from whichever layer set
6//! it, the merge can assemble a *combination* no single layer intended — and some such
7//! combinations are rejected by the kubelet or the API server. A rejected pod never reaches
8//! a terminal phase, so the Job's `backoffLimit` never trips and only the (long, hours-scale)
9//! `activeDeadlineSeconds` would ever stop it: the mover hangs, hammering the API, exactly
10//! the production failure that motivated this module.
11//!
12//! This is the single place that normalizes a resolved `(SecurityContext,
13//! PodSecurityContext)` pair into a spec the kubelet/apiserver always accept. Each invariant
14//! is a small, pure, individually-tested function; [`enforce_security_context_invariants`]
15//! composes them. Normalization only ever *relaxes a self-contradiction into the intent the
16//! merge clearly expressed* (e.g. an inherited root UID means "run as root") — it never
17//! grants privilege the layers didn't ask for, and elevated results are still caught by the
18//! privileged-mover gate ([`crate::common::requires_privilege_resolved`]).
19//!
20//! Adding a new invariant: write a `fn(SecurityContext[, PodSecurityContext]) -> …` that is
21//! idempotent (applying it twice equals applying it once) and a no-op on already-valid
22//! input, chain it in [`enforce_security_context_invariants`], and add a focused test plus a
23//! case to the `all_invariants_are_idempotent` test.
24
25use k8s_openapi::api::core::v1::{PodSecurityContext, SecurityContext};
26
27/// Capability whose presence in `capabilities.add` is, like `privileged: true`, incompatible
28/// with `allowPrivilegeEscalation: false` (API-server validation).
29const CAP_SYS_ADMIN: &str = "CAP_SYS_ADMIN";
30
31/// Enforce every security-context invariant on a fully-resolved mover `(container, pod)`
32/// context pair, returning a kubelet/apiserver-valid pair. Pure; the composition order is
33/// irrelevant because the invariants touch disjoint fields. Idempotent.
34pub fn enforce_security_context_invariants(
35    sc: SecurityContext,
36    psc: Option<PodSecurityContext>,
37) -> (SecurityContext, Option<PodSecurityContext>) {
38    // INV-1: a root effective UID is incompatible with `runAsNonRoot: true`.
39    let (sc, psc) = root_uid_excludes_run_as_non_root(sc, psc);
40    // INV-2: a privileged / CAP_SYS_ADMIN container is incompatible with
41    // `allowPrivilegeEscalation: false`.
42    let sc = privileged_excludes_no_privilege_escalation(sc);
43    (sc, psc)
44}
45
46/// **INV-1 — `runAsUser == 0` ⟹ `runAsNonRoot != true`.**
47///
48/// The kubelet rejects a container whose effective `runAsUser` is `0` while `runAsNonRoot`
49/// is `true` (*"container's runAsUser breaks non-root policy"*) and parks it in
50/// `CreateContainerConfigError`. This is produced when `inheritSecurityContextFrom` copies
51/// `runAsUser: 0` off a **root** workload while the hardened base
52/// ([`crate::common::hardened_security_context`]) still carries `runAsNonRoot: true`.
53///
54/// The effective UID follows kubelet precedence (`container.runAsUser ?? pod.runAsUser`), so
55/// a pod-level root UID also clears the container's `runAsNonRoot: true`. We flip
56/// `Some(true)` → `Some(false)` (not `None`) so the resulting root mover is *explicitly*
57/// root and is still recognized as elevated by the privileged-mover gate.
58fn root_uid_excludes_run_as_non_root(
59    mut sc: SecurityContext,
60    psc: Option<PodSecurityContext>,
61) -> (SecurityContext, Option<PodSecurityContext>) {
62    // Effective UID follows kubelet precedence (`container.runAsUser ?? pod.runAsUser`);
63    // share the single definition with `secctx_compat` so the two never fork.
64    let effective_run_as_user = crate::common::effective_run_as_user(Some(&sc), psc.as_ref());
65    if effective_run_as_user != Some(0) {
66        return (sc, psc);
67    }
68    if sc.run_as_non_root == Some(true) {
69        sc.run_as_non_root = Some(false);
70    }
71    let psc = psc.map(|mut p| {
72        if p.run_as_non_root == Some(true) {
73            p.run_as_non_root = Some(false);
74        }
75        p
76    });
77    (sc, psc)
78}
79
80/// **INV-2 — `privileged: true` (or `capabilities.add: [CAP_SYS_ADMIN]`) ⟹
81/// `allowPrivilegeEscalation != false`.**
82///
83/// The API server rejects a container that sets `allowPrivilegeEscalation: false` together
84/// with `privileged: true` or an added `CAP_SYS_ADMIN` (*"cannot set
85/// `allowPrivilegeEscalation` to false and `privileged` to true"*) — the Job's pod template
86/// is invalid, so no pod is ever created and the run hangs. This is produced when a workload
87/// or `moverDefaults` requests privilege while the hardened base
88/// ([`crate::common::hardened_security_context`]) still carries
89/// `allowPrivilegeEscalation: false`.
90///
91/// Privilege already implies escalation, so we clear the contradictory `Some(false)` →
92/// `Some(true)`, matching the privilege the layers asked for. The result is still caught by
93/// the privileged-mover gate.
94fn privileged_excludes_no_privilege_escalation(mut sc: SecurityContext) -> SecurityContext {
95    let demands_escalation = sc.privileged == Some(true)
96        || sc
97            .capabilities
98            .as_ref()
99            .and_then(|c| c.add.as_ref())
100            .is_some_and(|add| add.iter().any(|c| c == CAP_SYS_ADMIN));
101    if demands_escalation && sc.allow_privilege_escalation == Some(false) {
102        sc.allow_privilege_escalation = Some(true);
103    }
104    sc
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use k8s_openapi::api::core::v1::Capabilities;
111
112    /// Helper: a container SC with the hardened-relevant fields set.
113    fn sc(run_as_user: Option<i64>, run_as_non_root: Option<bool>) -> SecurityContext {
114        SecurityContext {
115            run_as_user,
116            run_as_non_root,
117            ..Default::default()
118        }
119    }
120
121    // --- INV-1 ---
122
123    #[test]
124    fn inv1_container_root_uid_clears_run_as_non_root() {
125        let (out, _) = enforce_security_context_invariants(sc(Some(0), Some(true)), None);
126        assert_eq!(out.run_as_user, Some(0));
127        assert_eq!(out.run_as_non_root, Some(false));
128    }
129
130    #[test]
131    fn inv1_pod_root_uid_clears_container_run_as_non_root() {
132        // Effective UID = container.runAsUser ?? pod.runAsUser → the pod's 0 wins.
133        let psc = PodSecurityContext {
134            run_as_user: Some(0),
135            ..Default::default()
136        };
137        let (out_sc, out_psc) =
138            enforce_security_context_invariants(sc(None, Some(true)), Some(psc));
139        assert_eq!(
140            out_sc.run_as_non_root,
141            Some(false),
142            "pod-level root UID clears the container's runAsNonRoot:true"
143        );
144        // The pod context had no runAsNonRoot to clear — left as-is, never Some(true).
145        assert_ne!(out_psc.unwrap().run_as_non_root, Some(true));
146    }
147
148    #[test]
149    fn inv1_pod_level_run_as_non_root_true_with_pod_root_uid_is_cleared() {
150        let psc = PodSecurityContext {
151            run_as_user: Some(0),
152            run_as_non_root: Some(true),
153            ..Default::default()
154        };
155        let (_, out_psc) =
156            enforce_security_context_invariants(SecurityContext::default(), Some(psc));
157        assert_eq!(out_psc.unwrap().run_as_non_root, Some(false));
158    }
159
160    #[test]
161    fn inv1_nonroot_uid_is_untouched() {
162        let (out, _) = enforce_security_context_invariants(sc(Some(2000), Some(true)), None);
163        assert_eq!(
164            out.run_as_non_root,
165            Some(true),
166            "non-root UID keeps the hardening"
167        );
168    }
169
170    #[test]
171    fn inv1_unset_run_as_non_root_with_root_uid_stays_unset() {
172        // Nothing to fix: runAsUser:0 with runAsNonRoot unset is already kubelet-valid.
173        let (out, _) = enforce_security_context_invariants(sc(Some(0), None), None);
174        assert_eq!(out.run_as_non_root, None);
175    }
176
177    // --- INV-2 ---
178
179    #[test]
180    fn inv2_privileged_clears_no_privilege_escalation() {
181        let input = SecurityContext {
182            privileged: Some(true),
183            allow_privilege_escalation: Some(false),
184            ..Default::default()
185        };
186        let (out, _) = enforce_security_context_invariants(input, None);
187        assert_eq!(
188            out.allow_privilege_escalation,
189            Some(true),
190            "privileged:true is incompatible with allowPrivilegeEscalation:false"
191        );
192    }
193
194    #[test]
195    fn inv2_cap_sys_admin_clears_no_privilege_escalation() {
196        let input = SecurityContext {
197            allow_privilege_escalation: Some(false),
198            capabilities: Some(Capabilities {
199                add: Some(vec!["CAP_SYS_ADMIN".to_string()]),
200                drop: Some(vec!["ALL".to_string()]),
201            }),
202            ..Default::default()
203        };
204        let (out, _) = enforce_security_context_invariants(input, None);
205        assert_eq!(out.allow_privilege_escalation, Some(true));
206        // Unrelated capabilities are untouched.
207        assert_eq!(out.capabilities.unwrap().drop.unwrap(), vec!["ALL"]);
208    }
209
210    #[test]
211    fn inv2_unprivileged_keeps_hardened_no_escalation() {
212        // The common case: no privilege requested → the hardened allowPrivilegeEscalation:false
213        // must survive (we must never relax hardening for a normal mover).
214        let input = SecurityContext {
215            allow_privilege_escalation: Some(false),
216            capabilities: Some(Capabilities {
217                add: Some(vec!["NET_BIND_SERVICE".to_string()]),
218                drop: Some(vec!["ALL".to_string()]),
219            }),
220            ..Default::default()
221        };
222        let (out, _) = enforce_security_context_invariants(input, None);
223        assert_eq!(out.allow_privilege_escalation, Some(false));
224    }
225
226    // --- composition / general properties ---
227
228    #[test]
229    fn combined_root_and_privileged_fixes_both() {
230        let input = SecurityContext {
231            run_as_user: Some(0),
232            run_as_non_root: Some(true),
233            privileged: Some(true),
234            allow_privilege_escalation: Some(false),
235            ..Default::default()
236        };
237        let (out, _) = enforce_security_context_invariants(input, None);
238        assert_eq!(out.run_as_non_root, Some(false));
239        assert_eq!(out.allow_privilege_escalation, Some(true));
240    }
241
242    #[test]
243    fn a_fully_hardened_nonroot_context_is_unchanged() {
244        // The default mover: runAsNonRoot:true, ape:false, no root UID, no privilege →
245        // every invariant is a no-op.
246        let input = crate::common::hardened_security_context();
247        let (out, _) = enforce_security_context_invariants(input.clone(), None);
248        assert_eq!(
249            out, input,
250            "invariants must not touch an already-valid hardened context"
251        );
252    }
253
254    #[test]
255    fn all_invariants_are_idempotent() {
256        // Applying the full set twice equals applying it once — a property every invariant
257        // must preserve so repeated reconciles never oscillate.
258        let cases = [
259            (sc(Some(0), Some(true)), None),
260            (
261                SecurityContext {
262                    privileged: Some(true),
263                    allow_privilege_escalation: Some(false),
264                    ..Default::default()
265                },
266                None,
267            ),
268            (
269                sc(None, Some(true)),
270                Some(PodSecurityContext {
271                    run_as_user: Some(0),
272                    run_as_non_root: Some(true),
273                    ..Default::default()
274                }),
275            ),
276        ];
277        for (sc_in, psc_in) in cases {
278            let once = enforce_security_context_invariants(sc_in.clone(), psc_in.clone());
279            let twice = enforce_security_context_invariants(once.0.clone(), once.1.clone());
280            assert_eq!(once, twice, "invariants must be idempotent");
281        }
282    }
283}