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}