kopiur_api/common/secctx.rs
1use k8s_openapi::api::core::v1::{
2 Capabilities, PodSecurityContext, ResourceRequirements, SeccompProfile, SecurityContext,
3};
4use k8s_openapi::apimachinery::pkg::api::resource::Quantity;
5use std::collections::BTreeMap;
6
7/// Whether a mover with the given **effective** container security context (the
8/// explicit `securityContext`, or the one resolved from `inheritSecurityContextFrom`),
9/// **pod** security context, and `privilegedMode` is privileged. The controller
10/// resolves an inherited context to a concrete `SecurityContext` and gates on *that* —
11/// so an inherited root context is caught exactly like an explicit one — and inspects
12/// the pod-level context too so a pod-level `runAsUser: 0` can't slip past. Pure +
13/// exhaustive: the single definition of "privileged" for both the spec-only
14/// ([`MoverSpec::requires_privilege`]) and the resolved paths.
15pub fn requires_privilege_resolved(
16 security_context: Option<&k8s_openapi::api::core::v1::SecurityContext>,
17 pod_security_context: Option<&k8s_openapi::api::core::v1::PodSecurityContext>,
18 privileged_mode: Option<bool>,
19) -> bool {
20 privileged_mode == Some(true)
21 || security_context.is_some_and(security_context_is_elevated)
22 || pod_security_context.is_some_and(pod_security_context_is_elevated)
23}
24
25/// Whether a container `SecurityContext` requests privileges beyond a normal
26/// unprivileged user (root UID, `privileged`, escalation, added capabilities, or an
27/// explicit `runAsNonRoot: false`). Pure helper for [`MoverSpec::requires_privilege`].
28pub fn security_context_is_elevated(sc: &k8s_openapi::api::core::v1::SecurityContext) -> bool {
29 sc.privileged == Some(true)
30 || sc.run_as_user == Some(0)
31 || sc.run_as_non_root == Some(false)
32 || sc.allow_privilege_escalation == Some(true)
33 || sc
34 .capabilities
35 .as_ref()
36 .and_then(|c| c.add.as_ref())
37 .is_some_and(|add| !add.is_empty())
38}
39
40/// Whether a **pod** `PodSecurityContext` requests root. Pod-level only carries a
41/// subset of the container knobs — `runAsUser` / `runAsNonRoot` are the ones that can
42/// make the mover root (capabilities/privileged are container-only). `fsGroup` and
43/// friends are NOT elevation. Pure helper for [`requires_privilege_resolved`].
44pub fn pod_security_context_is_elevated(
45 psc: &k8s_openapi::api::core::v1::PodSecurityContext,
46) -> bool {
47 psc.run_as_user == Some(0) || psc.run_as_non_root == Some(false)
48}
49
50/// The **effective** `runAsUser` following kubelet precedence: the container
51/// `securityContext.runAsUser` if set, else the pod `securityContext.runAsUser`. `None`
52/// when neither pins a UID — the UID is then image-determined (the `USER` line) and
53/// unknowable from the spec.
54///
55/// This is the single definition of effective-UID precedence, shared by
56/// [`crate::invariants`] (INV-1, which keys "is root" on this) and
57/// [`crate::secctx_compat`] (which keys read-compatibility on it) so the two can never
58/// fork. "Is root" is `effective_run_as_user(..) == Some(0)` — never `runAsNonRoot`,
59/// which the invariants may flip.
60pub fn effective_run_as_user(
61 sc: Option<&SecurityContext>,
62 psc: Option<&PodSecurityContext>,
63) -> Option<i64> {
64 sc.and_then(|s| s.run_as_user)
65 .or_else(|| psc.and_then(|p| p.run_as_user))
66}
67
68/// The **effective** `runAsGroup` following kubelet precedence — the gid peer of
69/// [`effective_run_as_user`]: container `securityContext.runAsGroup` if set, else the
70/// pod one. `None` when neither pins a group (image-determined).
71pub fn effective_run_as_group(
72 sc: Option<&SecurityContext>,
73 psc: Option<&PodSecurityContext>,
74) -> Option<i64> {
75 sc.and_then(|s| s.run_as_group)
76 .or_else(|| psc.and_then(|p| p.run_as_group))
77}
78
79/// THE layer merge: overlay one `(container, pod)` security-context layer pair onto
80/// another. Every layer fold — `resolve_mover`'s `hardened ⊂ moverDefaults ⊂ recipe`
81/// and the controller's `inherited ⊂ explicit` pre-fold — MUST go through this
82/// function; a lone per-dimension [`merge_security_context`]/[`merge_pod_security_context`]
83/// reintroduces cross-dimension identity shadowing.
84///
85/// Two steps:
86/// 1. Field-wise merge per dimension (the exhaustive-literal primitives above).
87/// 2. **Identity promotion to canonical form**: the kubelet resolves the effective
88/// UID/GID as `container ?? pod` ACROSS the two dimensions, so a lower layer's
89/// container-level `runAsUser` would silently shadow a higher layer's pod-level one —
90/// inverting the layer ladder (the matter-server bug). The merged pair therefore
91/// keeps its container context, when one exists, carrying the pair's **effective**
92/// UID/GID: the `over` layer's pinned identity wins, else the base's (hoisted from
93/// the pod level when that's where the winning layer wrote it). When no layer
94/// supplied a container context there is nothing to shadow, and the field-wise pod
95/// result already carries the `over` layer's identity.
96///
97/// The canonical form is what makes the merge **associative** (so the controller's
98/// pre-fold + `resolve_mover`'s fold equals a flat four-layer merge): with `E(L)` =
99/// layer `L`'s own effective UID, the merged container `runAsUser` is exactly
100/// `E(over).or(E(base))`, so any grouping of `hardened ⊂ moverDefaults ⊂ inherited ⊂
101/// explicit` resolves the identity of the **highest layer that pins one**. (A promotion
102/// keyed only on `over`'s own identity is NOT associative: once `over` is itself a
103/// merged pair, its pod-level identity — contributed by a lower layer — would be
104/// re-promoted in one grouping and not the other.) Identically for the GID. Within a
105/// single layer, container still beats pod — exactly the kubelet's own rule.
106pub fn merge_context_pair(
107 base_sc: Option<&SecurityContext>,
108 base_psc: Option<&PodSecurityContext>,
109 over_sc: Option<&SecurityContext>,
110 over_psc: Option<&PodSecurityContext>,
111) -> (Option<SecurityContext>, Option<PodSecurityContext>) {
112 let mut sc = merge_security_context_opt(base_sc, over_sc);
113 let psc = merge_pod_security_context_opt(base_psc, over_psc);
114 if let Some(merged) = sc.as_mut() {
115 // `E(over).or(E(base))`: `merged.run_as_user` already holds
116 // `over.sc.or(base.sc)`, so overriding with `E(over)` and falling through to
117 // the merged pod value yields `over.sc.or(over.psc).or(base.sc).or(base.psc)`.
118 merged.run_as_user = effective_run_as_user(over_sc, over_psc)
119 .or(merged.run_as_user)
120 .or_else(|| psc.as_ref().and_then(|p| p.run_as_user));
121 merged.run_as_group = effective_run_as_group(over_sc, over_psc)
122 .or(merged.run_as_group)
123 .or_else(|| psc.as_ref().and_then(|p| p.run_as_group));
124 }
125 (sc, psc)
126}
127
128/// The restricted-PSA-compatible **hardened** container security context (§4.11/G16):
129/// non-root, no privilege escalation, drop ALL caps, seccomp `RuntimeDefault`.
130///
131/// This is the LOWEST merge layer (ADR-0004 §2): `repo.moverDefaults.securityContext`
132/// then the recipe's `mover.securityContext` overlay it **field-wise**, so a partial
133/// override can only *tighten* — it never drops `capabilities.drop:[ALL]` /
134/// `seccompProfile`. Lives in `api` (not the controller) so the webhook and controller
135/// share one definition and both resolve the effective mover context identically.
136pub fn hardened_security_context() -> SecurityContext {
137 SecurityContext {
138 run_as_non_root: Some(true),
139 allow_privilege_escalation: Some(false),
140 read_only_root_filesystem: Some(false),
141 capabilities: Some(Capabilities {
142 drop: Some(vec!["ALL".to_string()]),
143 add: None,
144 }),
145 seccomp_profile: Some(SeccompProfile {
146 type_: "RuntimeDefault".to_string(),
147 localhost_profile: None,
148 }),
149 ..Default::default()
150 }
151}
152
153/// The nonroot UID/GID baked into the mover image (`docker/Dockerfile.mover`:
154/// `USER 65532:65532`, distroless `nonroot`). The hardened **pod** context defaults
155/// `fsGroup` to this so the kubelet group-owns every mounted volume to the gid the
156/// mover actually runs as — most importantly the operator-managed kopia cache, which
157/// is otherwise created `root:root` on PVC-backed storage and unwritable by the
158/// unprivileged mover. Centralized here (the single source of the hardened defaults)
159/// so the value can never drift from the image.
160pub const MOVER_NONROOT_ID: i64 = 65532;
161
162/// The restricted-PSA-compatible **hardened pod** security context — the pod-level
163/// peer of [`hardened_security_context`]. Defaults `fsGroup` to [`MOVER_NONROOT_ID`]
164/// so every mover pod's volumes (notably the cache) are writable by the unprivileged
165/// mover; `fsGroupChangePolicy: OnRootMismatch` skips the recursive chown when the
166/// volume root already matches, so it does not needlessly rewrite ownership on every
167/// run.
168///
169/// Same merge story as the container context (ADR-0004 §2): this is the LOWEST layer,
170/// overlaid field-wise by `repo.moverDefaults.podSecurityContext` then the recipe's
171/// `mover.podSecurityContext`, so any of `fsGroup`/`runAsUser`/… can be overridden
172/// (e.g. a restore that must own files as the app's UID) while unset fields keep the
173/// hardened default. Lives in `api` so the webhook and controller resolve it identically.
174pub fn hardened_pod_security_context() -> PodSecurityContext {
175 PodSecurityContext {
176 fs_group: Some(MOVER_NONROOT_ID),
177 fs_group_change_policy: Some("OnRootMismatch".to_string()),
178 ..Default::default()
179 }
180}
181
182/// Deep-merge two [`Capabilities`]: each of `add`/`drop` is taken from `over` when set,
183/// else from `base`. So an `over` that sets only `add` keeps `base.drop` — an add-only
184/// override never silently drops the hardened `drop:[ALL]` (the bug ADR-0004 §2 cites).
185pub fn merge_capabilities(base: &Capabilities, over: &Capabilities) -> Capabilities {
186 Capabilities {
187 add: over.add.clone().or_else(|| base.add.clone()),
188 drop: over.drop.clone().or_else(|| base.drop.clone()),
189 }
190}
191
192/// Field-wise overlay of container [`SecurityContext`] `over` onto `base`: each `Some`
193/// field in `over` wins, unset fields inherit `base`; `capabilities` deep-merge via
194/// [`merge_capabilities`] (ADR-0004 §2).
195///
196/// The struct literal is **exhaustive** (no `..base` tail) on purpose: when the pinned
197/// k8s-openapi `SecurityContext` gains a field, this stops compiling until the new field
198/// is considered — the same discipline as the exhaustive-`match` enum thesis (§5.5).
199pub fn merge_security_context(base: &SecurityContext, over: &SecurityContext) -> SecurityContext {
200 SecurityContext {
201 allow_privilege_escalation: over
202 .allow_privilege_escalation
203 .or(base.allow_privilege_escalation),
204 app_armor_profile: over
205 .app_armor_profile
206 .clone()
207 .or_else(|| base.app_armor_profile.clone()),
208 capabilities: match (base.capabilities.as_ref(), over.capabilities.as_ref()) {
209 (Some(b), Some(o)) => Some(merge_capabilities(b, o)),
210 (b, o) => o.cloned().or_else(|| b.cloned()),
211 },
212 privileged: over.privileged.or(base.privileged),
213 proc_mount: over.proc_mount.clone().or_else(|| base.proc_mount.clone()),
214 read_only_root_filesystem: over
215 .read_only_root_filesystem
216 .or(base.read_only_root_filesystem),
217 run_as_group: over.run_as_group.or(base.run_as_group),
218 run_as_non_root: over.run_as_non_root.or(base.run_as_non_root),
219 run_as_user: over.run_as_user.or(base.run_as_user),
220 se_linux_options: over
221 .se_linux_options
222 .clone()
223 .or_else(|| base.se_linux_options.clone()),
224 seccomp_profile: over
225 .seccomp_profile
226 .clone()
227 .or_else(|| base.seccomp_profile.clone()),
228 windows_options: over
229 .windows_options
230 .clone()
231 .or_else(|| base.windows_options.clone()),
232 }
233}
234
235/// Field-wise overlay of pod [`PodSecurityContext`] `over` onto `base`. Exhaustive
236/// literal for the same reason as [`merge_security_context`].
237pub fn merge_pod_security_context(
238 base: &PodSecurityContext,
239 over: &PodSecurityContext,
240) -> PodSecurityContext {
241 PodSecurityContext {
242 app_armor_profile: over
243 .app_armor_profile
244 .clone()
245 .or_else(|| base.app_armor_profile.clone()),
246 fs_group: over.fs_group.or(base.fs_group),
247 fs_group_change_policy: over
248 .fs_group_change_policy
249 .clone()
250 .or_else(|| base.fs_group_change_policy.clone()),
251 run_as_group: over.run_as_group.or(base.run_as_group),
252 run_as_non_root: over.run_as_non_root.or(base.run_as_non_root),
253 run_as_user: over.run_as_user.or(base.run_as_user),
254 se_linux_change_policy: over
255 .se_linux_change_policy
256 .clone()
257 .or_else(|| base.se_linux_change_policy.clone()),
258 se_linux_options: over
259 .se_linux_options
260 .clone()
261 .or_else(|| base.se_linux_options.clone()),
262 seccomp_profile: over
263 .seccomp_profile
264 .clone()
265 .or_else(|| base.seccomp_profile.clone()),
266 supplemental_groups: over
267 .supplemental_groups
268 .clone()
269 .or_else(|| base.supplemental_groups.clone()),
270 supplemental_groups_policy: over
271 .supplemental_groups_policy
272 .clone()
273 .or_else(|| base.supplemental_groups_policy.clone()),
274 sysctls: over.sysctls.clone().or_else(|| base.sysctls.clone()),
275 windows_options: over
276 .windows_options
277 .clone()
278 .or_else(|| base.windows_options.clone()),
279 }
280}
281
282/// Per-key merge of two `limits`/`requests` quantity maps: `over` keys win, `base` keys
283/// fill. Returns `None` only when both are absent.
284fn merge_quantity_map(
285 base: Option<&BTreeMap<String, Quantity>>,
286 over: Option<&BTreeMap<String, Quantity>>,
287) -> Option<BTreeMap<String, Quantity>> {
288 match (base, over) {
289 (None, None) => None,
290 (Some(b), None) => Some(b.clone()),
291 (None, Some(o)) => Some(o.clone()),
292 (Some(b), Some(o)) => {
293 let mut merged = b.clone();
294 for (k, v) in o {
295 merged.insert(k.clone(), v.clone());
296 }
297 Some(merged)
298 }
299 }
300}
301
302/// Field-wise overlay of [`ResourceRequirements`]: `limits`/`requests` merge per-key
303/// (via `merge_quantity_map`); `claims` is taken from `over` when set, else `base`.
304pub fn merge_resources(
305 base: &ResourceRequirements,
306 over: &ResourceRequirements,
307) -> ResourceRequirements {
308 ResourceRequirements {
309 claims: over.claims.clone().or_else(|| base.claims.clone()),
310 limits: merge_quantity_map(base.limits.as_ref(), over.limits.as_ref()),
311 requests: merge_quantity_map(base.requests.as_ref(), over.requests.as_ref()),
312 }
313}
314
315/// `Option`-aware [`merge_security_context`] (handles the four `None`/`Some` cases).
316pub fn merge_security_context_opt(
317 base: Option<&SecurityContext>,
318 over: Option<&SecurityContext>,
319) -> Option<SecurityContext> {
320 match (base, over) {
321 (None, None) => None,
322 (Some(b), None) => Some(b.clone()),
323 (None, Some(o)) => Some(o.clone()),
324 (Some(b), Some(o)) => Some(merge_security_context(b, o)),
325 }
326}
327
328/// `Option`-aware [`merge_pod_security_context`].
329pub fn merge_pod_security_context_opt(
330 base: Option<&PodSecurityContext>,
331 over: Option<&PodSecurityContext>,
332) -> Option<PodSecurityContext> {
333 match (base, over) {
334 (None, None) => None,
335 (Some(b), None) => Some(b.clone()),
336 (None, Some(o)) => Some(o.clone()),
337 (Some(b), Some(o)) => Some(merge_pod_security_context(b, o)),
338 }
339}
340
341/// `Option`-aware [`merge_resources`].
342pub fn merge_resources_opt(
343 base: Option<&ResourceRequirements>,
344 over: Option<&ResourceRequirements>,
345) -> Option<ResourceRequirements> {
346 match (base, over) {
347 (None, None) => None,
348 (Some(b), None) => Some(b.clone()),
349 (None, Some(o)) => Some(o.clone()),
350 (Some(b), Some(o)) => Some(merge_resources(b, o)),
351 }
352}