Skip to main content

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}