From a6b399cd4d10328f70e5ef9861e25ea30af6112c Mon Sep 17 00:00:00 2001 From: Jeff Larson Date: Sat, 1 Aug 2026 13:15:16 -0700 Subject: [PATCH 1/2] feat(engine): route-transitive Exposure::Internet through declared Ingress routes (ADR-0038) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add IngressExposureAdapter: a backend Service that a live, internet-exposed Ingress controller routes to is promoted to Exposure::Internet, so it becomes a normal entry and flows through the existing edge-CVE promotion lane with zero change to proof, prompt, guards, or menu. Entry derivation already keys solely on Workload.exposure (reason/proof/chain.rs), so setting that field is the entire integration surface. Controller-anchoring (D1) matches a live controller's Ingress.status.loadBalancer address against its own fronting Service's status.loadBalancer address — the one deterministic, controller-agnostic K8s signal available, since IngressClass carries no object reference to the workload implementing spec.controller. An Ingress whose controller never claimed it with a live address doesn't propagate (under-promote fail direction). Promotion runs to a bounded fixpoint (D2, bounded by node count) so a promoted backend can anchor further routes it serves ("chains compose"). Plumbs Snapshot.ingresses/ingress_classes through the poll observer and the watch reflectors, and grants read-only networking.k8s.io ingresses/ ingressclasses RBAC. Flips ADR-0038 to Accepted with a D1/D2 addendum. Closes JEF-697. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP --- charts/protector/templates/clusterrole.yaml | 6 + ...-transitive-internet-exposure-l7-routes.md | 28 +- .../observe/adapter/ingress_exposure.rs | 253 ++++++++++++++ .../observe/adapter/ingress_exposure/tests.rs | 329 ++++++++++++++++++ engine/src/engine/observe/adapter/mod.rs | 7 + engine/src/engine/observe/mod.rs | 25 +- engine/src/engine/run_loop.rs | 15 +- 7 files changed, 659 insertions(+), 4 deletions(-) create mode 100644 engine/src/engine/observe/adapter/ingress_exposure.rs create mode 100644 engine/src/engine/observe/adapter/ingress_exposure/tests.rs diff --git a/charts/protector/templates/clusterrole.yaml b/charts/protector/templates/clusterrole.yaml index b0e63cbc..8a310e8f 100644 --- a/charts/protector/templates/clusterrole.yaml +++ b/charts/protector/templates/clusterrole.yaml @@ -22,6 +22,12 @@ rules: - apiGroups: ["networking.k8s.io"] resources: ["networkpolicies"] verbs: ["get", "list", "watch"] + # Declared L7 routes (ADR-0038): the IngressExposureAdapter walks Ingress/IngressClass + # objects to make internet-exposure follow declared routing, so a route-forwarded + # backend of a live internet-exposed controller becomes an entry. Read-only. + - apiGroups: ["networking.k8s.io"] + resources: ["ingresses", "ingressclasses"] + verbs: ["get", "list", "watch"] - apiGroups: ["rbac.authorization.k8s.io"] resources: ["roles", "rolebindings", "clusterroles", "clusterrolebindings"] verbs: ["get", "list", "watch"] diff --git a/docs/adr/0038-transitive-internet-exposure-l7-routes.md b/docs/adr/0038-transitive-internet-exposure-l7-routes.md index bac9153d..122efe58 100644 --- a/docs/adr/0038-transitive-internet-exposure-l7-routes.md +++ b/docs/adr/0038-transitive-internet-exposure-l7-routes.md @@ -1,6 +1,6 @@ # 0038. Transitive internet exposure through declared L7 routes -- Status: Proposed +- Status: Accepted - Date: 2026-08-01 ## Context @@ -65,4 +65,28 @@ target cluster actually has the CRDs; otherwise it is not built. arming ladder ([ADR-0035](0035-per-cut-class-arming-ladder.md)) and the blast gate apply untouched. - Retires ADR-0012's "Ingress/Gateway-API exposure is unmodeled" caveat for the Ingress case. -- The implementing change flips this ADR to Accepted. + +## Addendum — implementation decisions (Ingress observer) + +Two decisions the Decision section above left to the implementation, recorded here now that +`IngressExposureAdapter` (`engine/src/engine/observe/adapter/ingress_exposure.rs`) exists: + +- **D1 — controller-anchoring.** Kubernetes has no object linking an `IngressClass` to the + workload that implements its `spec.controller` string; that string is an opaque identifier, + not an object reference. Rather than guess via a naming/label convention (which reopens + exactly the over-promotion hazard this ADR closes), the adapter uses the one piece of the + API that is both deterministic and controller-agnostic: a live controller stamps + `Ingress.status.loadBalancer` with the address it serves that Ingress from, and stamps the + *identical* address on its own fronting Service's `status.loadBalancer`. Matching those two + addresses finds the exact controller workload with no convention and no fabrication risk. An + Ingress whose controller has never (or not yet) claimed it with a live address simply never + matches — the under-promote fail direction. +- **D2 — bounded fixpoint.** "Chains compose" requires re-deriving controller-liveness against + the graph's *current* exposure facts, not just the facts `ExposureAdapter` computed before + this adapter ran (a backend promoted in one pass can itself be the live, address-matched + controller for a further route). `contribute` re-scans every Ingress until a full pass makes + no new promotion, bounded by `graph.node_count()` — the worst case for how many distinct + workloads could ever still be pending promotion, so a cycle can never spin the engine loop. + Converges in one pass per hop of chaining in practice (rare beyond 1-2). + +The implementing change flips this ADR to Accepted. diff --git a/engine/src/engine/observe/adapter/ingress_exposure.rs b/engine/src/engine/observe/adapter/ingress_exposure.rs new file mode 100644 index 00000000..cf8008a9 --- /dev/null +++ b/engine/src/engine/observe/adapter/ingress_exposure.rs @@ -0,0 +1,253 @@ +//! Route-transitive internet exposure (ADR-0038): a backend Service that a live, +//! internet-exposed Ingress controller routes to inherits `Exposure::Internet`, so it +//! becomes a normal entry and flows through the *existing* edge-CVE promotion lane +//! (`reason/proof/chain.rs` derives every entry from `Workload.exposure`) with zero +//! change to proof, prompt, guards, or menu. +//! +//! **Controller-anchoring (D1).** Kubernetes has no object that names *which* +//! workload implements an `IngressClass`'s controller — `spec.controller` is an +//! opaque identifier string, not an object reference. Guessing via a naming/label +//! convention would risk exactly the over-promotion hazard ADR-0038 calls out. This +//! adapter instead uses the one piece of the API that *is* deterministic and +//! controller-agnostic: a live controller stamps `Ingress.status.loadBalancer` with +//! the address it is serving that Ingress from, and stamps the identical address on +//! its own fronting Service's `status.loadBalancer` (the "internet → LB → ingress → +//! backend" chain ADR-0038 names). Matching those two addresses finds the exact +//! controller workload with no naming convention and no fabrication risk — an +//! Ingress whose controller hasn't (yet, or ever) claimed it with a live address +//! simply never matches (fail-safe: under-promote). +//! +//! **Bounded fixpoint (D2).** "Chains compose" (ADR-0038): a backend just promoted to +//! `Exposure::Internet` can itself be the live, address-matched controller for a +//! *further* route it serves. [`IngressExposureAdapter::contribute`] therefore +//! re-scans every Ingress until a full pass makes no new promotion, bounded by the +//! graph's node count — the worst case for how many distinct workloads could ever +//! still be pending promotion, so a cycle can never spin the loop. In practice this +//! converges in one pass per hop of chaining (rare beyond 1-2). + +use k8s_openapi::api::core::v1::LoadBalancerIngress; +use k8s_openapi::api::networking::v1::{Ingress, IngressClass, IngressLoadBalancerIngress}; + +use super::*; + +/// The well-known, upstream Kubernetes annotation (not a protector convention) that +/// marks the single `IngressClass` new unclassed `Ingress` objects resolve to. +const DEFAULT_INGRESS_CLASS_ANNOTATION: &str = "ingressclass.kubernetes.io/is-default-class"; + +/// Sets a route-forwarded backend's `exposure` fact to `Exposure::Internet` when a +/// live, internet-exposed controller actually serves the route (ADR-0038). See the +/// module docs for the controller-anchoring and fixpoint rules. Reads and rewrites +/// the Workload nodes [`WorkloadAdapter`] created and [`ExposureAdapter`] already +/// stamped, so it must run after both. +pub struct IngressExposureAdapter; + +impl Adapter for IngressExposureAdapter { + fn name(&self) -> &'static str { + "ingress_exposure" + } + + fn contribute(&self, snapshot: &Snapshot, graph: &mut SecurityGraph) { + // Bounded fixpoint (D2): see the module docs. `max(1)` so an empty graph still + // runs one (no-op) pass rather than zero. + for _ in 0..graph.node_count().max(1) { + let mut changed = false; + for ingress in &snapshot.ingresses { + let Some(namespace) = ingress.metadata.namespace.as_deref() else { + continue; + }; + if !controller_is_live(snapshot, graph, ingress) { + continue; + } + for service_name in ingress_backend_service_names(ingress) { + let Some(service) = snapshot.services.iter().find(|s| { + s.metadata.namespace.as_deref() == Some(namespace) + && s.metadata.name.as_deref() == Some(service_name) + }) else { + continue; + }; + let Some(selector) = service.spec.as_ref().and_then(|s| s.selector.as_ref()) + else { + continue; + }; + for pod in selected_pods(snapshot, namespace, selector) { + let Some(pod_name) = pod.metadata.name.as_deref() else { + continue; + }; + let key = workload_node(namespace, pod_name).key(); + let mut promoted = false; + graph.update_node(&key, |node| { + if let Node::Workload(w) = node + && w.exposure != Exposure::Internet + { + w.exposure = Exposure::Internet; + promoted = true; + } + }); + changed |= promoted; + } + } + } + if !changed { + break; + } + } + } +} + +/// Whether `ingress` is currently served by a live, internet-exposed controller +/// (ADR-0038 "controller-anchored"): its declared class must resolve to a real +/// `IngressClass`, and a Service's `status.loadBalancer` address must match the +/// Ingress's own — proof that a live controller actually claimed this specific +/// route — and that Service's selected pods must themselves be `Exposure::Internet` +/// in the graph (observed, declared, or promoted by an earlier fixpoint round). +fn controller_is_live(snapshot: &Snapshot, graph: &SecurityGraph, ingress: &Ingress) -> bool { + let Some(ingress_lb) = ingress + .status + .as_ref() + .and_then(|s| s.load_balancer.as_ref()) + .and_then(|lb| lb.ingress.as_ref()) + .filter(|lb| !lb.is_empty()) + else { + return false; + }; + // The referenced class must exist and carry a real controller identity — closes + // the orphan/typo'd-class over-promotion path (an under-promote fail direction: + // a route naming no resolvable class never propagates, ADR-0038). + let Some(class) = resolve_ingress_class(snapshot, ingress) else { + return false; + }; + if class + .spec + .as_ref() + .and_then(|s| s.controller.as_ref()) + .is_none() + { + return false; + } + + snapshot.services.iter().any(|svc| { + let Some(svc_lb) = svc + .status + .as_ref() + .and_then(|s| s.load_balancer.as_ref()) + .and_then(|lb| lb.ingress.as_ref()) + else { + return false; + }; + if !lb_addresses_overlap(ingress_lb, svc_lb) { + return false; + } + let Some(ns) = svc.metadata.namespace.as_deref() else { + return false; + }; + let Some(selector) = svc.spec.as_ref().and_then(|s| s.selector.as_ref()) else { + return false; + }; + selected_pods(snapshot, ns, selector).any(|pod| workload_is_internet(graph, ns, pod)) + }) +} + +/// True if any `(ip, hostname)` entry in `ingress_lb` shares a non-empty `ip` or +/// `hostname` with an entry in `svc_lb` — the same address a live controller +/// publishes both on the Ingress it serves and on its own fronting Service. +fn lb_addresses_overlap( + ingress_lb: &[IngressLoadBalancerIngress], + svc_lb: &[LoadBalancerIngress], +) -> bool { + ingress_lb.iter().any(|il| { + svc_lb.iter().any(|sl| { + (il.ip.is_some() && il.ip == sl.ip) + || (il.hostname.is_some() && il.hostname == sl.hostname) + }) + }) +} + +/// Resolves `ingress`'s `IngressClass`: the explicitly named one, or — when +/// `ingressClassName` is unset — the cluster's single class carrying the upstream +/// [`DEFAULT_INGRESS_CLASS_ANNOTATION`]. Neither resolving is the orphan case +/// [`controller_is_live`] fails on. +fn resolve_ingress_class<'a>( + snapshot: &'a Snapshot, + ingress: &Ingress, +) -> Option<&'a IngressClass> { + if let Some(name) = ingress + .spec + .as_ref() + .and_then(|s| s.ingress_class_name.as_deref()) + { + return snapshot + .ingress_classes + .iter() + .find(|c| c.metadata.name.as_deref() == Some(name)); + } + snapshot.ingress_classes.iter().find(|c| { + c.metadata + .annotations + .as_ref() + .and_then(|a| a.get(DEFAULT_INGRESS_CLASS_ANNOTATION)) + .is_some_and(|v| v == "true") + }) +} + +/// Every backend Service name `ingress` routes to — its `defaultBackend` plus every +/// rule path's backend. A `resource` backend (not a Service) is skipped; it names a +/// non-Service object this adapter has no workload to promote. Always in the +/// Ingress's own namespace (the only namespace `IngressServiceBackend` can name). +fn ingress_backend_service_names(ingress: &Ingress) -> Vec<&str> { + let Some(spec) = ingress.spec.as_ref() else { + return Vec::new(); + }; + let mut names: Vec<&str> = spec + .default_backend + .as_ref() + .and_then(|b| b.service.as_ref()) + .map(|s| s.name.as_str()) + .into_iter() + .collect(); + for rule in spec.rules.iter().flatten() { + let Some(http) = rule.http.as_ref() else { + continue; + }; + for path in &http.paths { + if let Some(service) = path.backend.service.as_ref() { + names.push(service.name.as_str()); + } + } + } + names +} + +/// Pods in `namespace` matching `selector` — the same "all key/value pairs present" +/// rule [`ExposureAdapter`] uses for Service→pod selection. An empty selector +/// matches nothing here (never "every pod in the namespace"), the same +/// over-promotion guard `ExposureAdapter::contribute` applies. +fn selected_pods<'a>( + snapshot: &'a Snapshot, + namespace: &str, + selector: &BTreeMap, +) -> impl Iterator { + snapshot.pods.iter().filter(move |pod| { + !selector.is_empty() && pod_namespace(pod) == namespace && { + // Hoisted out of the `all()` closure so a multi-key selector clones the + // pod's labels once, not once per key. + let labels = pod_labels(pod); + selector.iter().all(|(k, v)| labels.get(k) == Some(v)) + } + }) +} + +/// Whether the Workload node for `pod` (in `namespace`) is currently +/// `Exposure::Internet` in `graph`. +fn workload_is_internet(graph: &SecurityGraph, namespace: &str, pod: &Pod) -> bool { + let Some(name) = pod.metadata.name.as_deref() else { + return false; + }; + let key = workload_node(namespace, name).key(); + matches!( + graph.index_of(&key).and_then(|i| graph.node(i)), + Some(Node::Workload(w)) if w.exposure == Exposure::Internet + ) +} + +#[cfg(test)] +mod tests; diff --git a/engine/src/engine/observe/adapter/ingress_exposure/tests.rs b/engine/src/engine/observe/adapter/ingress_exposure/tests.rs new file mode 100644 index 00000000..3c7ea61a --- /dev/null +++ b/engine/src/engine/observe/adapter/ingress_exposure/tests.rs @@ -0,0 +1,329 @@ +//! ADR-0038 coverage: route-forwarded backends promote through a live controller, +//! orphan routes don't (the fail-safe direction), chains compose one further hop via +//! the bounded fixpoint, and the ADR-0012 declared annotation still wins when there +//! is no in-cluster Ingress at all. These are graph-level tests — several drive the +//! full `reason::proof::prove` walk — precisely to demonstrate the promoted fact +//! reuses the *existing* entry lane with no new evidence class or graph relation, as +//! ADR-0038 requires. None of them touch `reason::proof`'s own test files. + +use super::*; +use crate::engine::graph::{Provenance, Severity, Vulnerability}; +use crate::engine::observe::ImageVulnerabilities; +use crate::engine::observe::adapter::test_support::pod; +use crate::engine::reason::proof::prove; +use k8s_openapi::api::core::v1::Service; +use serde_json::{Value, json}; +use std::time::SystemTime; + +fn service(value: Value) -> Service { + serde_json::from_value(value).expect("valid Service fixture") +} + +fn ingress(value: Value) -> Ingress { + serde_json::from_value(value).expect("valid Ingress fixture") +} + +fn ingress_class(value: Value) -> IngressClass { + serde_json::from_value(value).expect("valid IngressClass fixture") +} + +/// A LoadBalancer-fronted controller pod, live at `ip` for `class_name` +/// (controller identity `controller`), plus the `IngressClass` object it serves. +fn live_controller( + namespace: &str, + class_name: &str, + controller: &str, + ip: &str, +) -> (Service, Vec) { + let svc = service(json!({ + "apiVersion": "v1", "kind": "Service", + "metadata": {"name": format!("{class_name}-controller"), "namespace": namespace}, + "spec": {"type": "LoadBalancer", "selector": {"app": format!("{class_name}-controller")}}, + "status": {"loadBalancer": {"ingress": [{"ip": ip}]}} + })); + let controller_pod = json!({ + "apiVersion": "v1", "kind": "Pod", + "metadata": { + "name": format!("{class_name}-controller-pod"), "namespace": namespace, + "labels": {"app": format!("{class_name}-controller")} + }, + "spec": {"containers": [{"name": "c", "image": "controller:1"}]} + }); + let class = json!({ + "apiVersion": "networking.k8s.io/v1", "kind": "IngressClass", + "metadata": {"name": class_name}, + "spec": {"controller": controller} + }); + (svc, vec![controller_pod, class]) +} + +fn workload_exposure(graph: &SecurityGraph, namespace: &str, name: &str) -> Exposure { + match graph.node( + graph + .index_of(&workload_node(namespace, name).key()) + .unwrap(), + ) { + Some(Node::Workload(w)) => w.exposure, + other => panic!("expected a Workload node, got {other:?}"), + } +} + +/// A route-forwarded backend of a live internet-exposed controller is promoted to +/// `Exposure::Internet` and — carrying a critical CVE — proves an EXPLOIT_PUBLIC_FACING +/// foothold, exactly the existing edge-CVE entry lane (ADR-0038's whole point: zero new +/// evidence class, zero new prompt vocabulary). +#[test] +fn route_forwarded_backend_of_live_controller_becomes_a_proven_entry() { + use crate::engine::graph::attack::EXPLOIT_PUBLIC_FACING; + + let (controller_svc, controller_extras) = + live_controller("edge", "nginx", "k8s.io/ingress-nginx", "203.0.113.10"); + let web_ingress = ingress(json!({ + "apiVersion": "networking.k8s.io/v1", "kind": "Ingress", + "metadata": {"name": "web-ingress", "namespace": "app"}, + "spec": { + "ingressClassName": "nginx", + "rules": [{"host": "web.example.com", "http": {"paths": [ + {"path": "/", "pathType": "Prefix", "backend": {"service": {"name": "web-svc", "port": {"number": 80}}}} + ]}}] + }, + "status": {"loadBalancer": {"ingress": [{"ip": "203.0.113.10"}]}} + })); + let web_svc = service(json!({ + "apiVersion": "v1", "kind": "Service", + "metadata": {"name": "web-svc", "namespace": "app"}, + "spec": {"type": "ClusterIP", "selector": {"app": "web"}} + })); + // Mounts a secret directly, so proving has a recognized objective to reach — + // mirrors `reason::proof`'s own `proves_foothold_when_exposed_and_exploitable` + // fixture shape. + let web_pod = pod(json!({ + "apiVersion": "v1", "kind": "Pod", + "metadata": {"name": "web", "namespace": "app", "labels": {"app": "web"}}, + "spec": { + "containers": [{ + "name": "web", "image": "web:1", + "envFrom": [{"secretRef": {"name": "session-key"}}] + }] + } + })); + + let snap = Snapshot { + pods: vec![pod(controller_extras[0].clone()), web_pod], + services: vec![controller_svc, web_svc], + ingresses: vec![web_ingress], + ingress_classes: vec![ingress_class(controller_extras[1].clone())], + secrets: vec![crate::engine::observe::SecretMeta { + namespace: "app".into(), + name: "session-key".into(), + }], + image_vulns: vec![ImageVulnerabilities { + image: "web:1".into(), + vulnerabilities: vec![Vulnerability { + id: "CVE-2026-1111".into(), + severity: Severity::Critical, + exploited_in_wild: true, + epss: None, + sources: vec![Provenance::new("trivy", SystemTime::UNIX_EPOCH)], + ..Default::default() + }], + }], + ..Default::default() + }; + + let graph = build_graph(&snap, &default_adapters()); + assert_eq!(workload_exposure(&graph, "app", "web"), Exposure::Internet); + + let chains = prove(&graph); + let chain = chains + .iter() + .find(|c| c.entry.0 == "workload/app/Pod/web") + .expect("route-forwarded backend is a proven entry"); + assert_eq!(chain.foothold, Some(EXPLOIT_PUBLIC_FACING)); +} + +/// An Ingress with no live internet-exposed controller does NOT promote its +/// backend — the ADR-0038 fail-safe (under-promote) direction. Covers both orphan +/// shapes: a class that doesn't resolve, and a resolvable class whose controller +/// never actually claimed this Ingress (no matching live address). +#[test] +fn orphan_ingress_does_not_promote() { + let unresolved_class = ingress(json!({ + "apiVersion": "networking.k8s.io/v1", "kind": "Ingress", + "metadata": {"name": "typo-ingress", "namespace": "app"}, + "spec": { + "ingressClassName": "does-not-exist", + "rules": [{"http": {"paths": [ + {"path": "/", "pathType": "Prefix", "backend": {"service": {"name": "orphan-a-svc", "port": {"number": 80}}}} + ]}}] + }, + "status": {"loadBalancer": {"ingress": [{"ip": "203.0.113.99"}]}} + })); + let not_live = ingress(json!({ + "apiVersion": "networking.k8s.io/v1", "kind": "Ingress", + "metadata": {"name": "unclaimed-ingress", "namespace": "app"}, + "spec": { + "ingressClassName": "nginx", + "rules": [{"http": {"paths": [ + {"path": "/", "pathType": "Prefix", "backend": {"service": {"name": "orphan-b-svc", "port": {"number": 80}}}} + ]}}] + } + // No status.loadBalancer at all: the controller has never actually served it. + })); + let (controller_svc, controller_extras) = + live_controller("edge", "nginx", "k8s.io/ingress-nginx", "203.0.113.10"); + + let backend = |name: &str, role: &str| { + ( + service(json!({ + "apiVersion": "v1", "kind": "Service", + "metadata": {"name": format!("{role}-svc"), "namespace": "app"}, + "spec": {"type": "ClusterIP", "selector": {"app": role}} + })), + pod(json!({ + "apiVersion": "v1", "kind": "Pod", + "metadata": {"name": name, "namespace": "app", "labels": {"app": role}}, + "spec": {"containers": [{"name": "c", "image": "x:1"}]} + })), + ) + }; + let (svc_a, pod_a) = backend("orphan-a", "orphan-a"); + let (svc_b, pod_b) = backend("orphan-b", "orphan-b"); + + let snap = Snapshot { + pods: vec![pod(controller_extras[0].clone()), pod_a, pod_b], + services: vec![controller_svc, svc_a, svc_b], + ingresses: vec![unresolved_class, not_live], + ingress_classes: vec![ingress_class(controller_extras[1].clone())], + ..Default::default() + }; + + // Each has an ordinary ClusterIP-selecting Service, so base ExposureAdapter marks + // them ClusterExposed — the assertion is that they stay BELOW Internet (never + // promoted), not that they're fully unexposed. + let graph = build_graph(&snap, &default_adapters()); + assert_eq!( + workload_exposure(&graph, "app", "orphan-a"), + Exposure::ClusterExposed + ); + assert_eq!( + workload_exposure(&graph, "app", "orphan-b"), + Exposure::ClusterExposed + ); +} + +/// A backend promoted to `Exposure::Internet` by one route can itself anchor a +/// FURTHER route it serves ("chains compose", ADR-0038) — the bounded fixpoint +/// re-checks live-controller status against the graph's current exposure facts, not +/// just the facts ExposureAdapter computed before this adapter ran. `gateway-svc`'s +/// `status.loadBalancer` is set here purely to model "gateway now also routes +/// traffic on this address" for the test — the mechanism under test is the +/// fixpoint's re-derivation, not a claim about which Service types get such status +/// in a real cluster. +#[test] +fn chains_compose_one_further_hop() { + let (outer_svc, outer_extras) = + live_controller("edge", "outer", "vendor/outer", "198.51.100.1"); + let outer_ingress = ingress(json!({ + "apiVersion": "networking.k8s.io/v1", "kind": "Ingress", + "metadata": {"name": "outer-ingress", "namespace": "edge"}, + "spec": { + "ingressClassName": "outer", + "rules": [{"http": {"paths": [ + {"path": "/", "pathType": "Prefix", "backend": {"service": {"name": "gateway-svc", "port": {"number": 80}}}} + ]}}] + }, + "status": {"loadBalancer": {"ingress": [{"ip": "198.51.100.1"}]}} + })); + // Not internet-exposed on its own (ClusterIP) — only the outer route promotes it. + let gateway_svc = service(json!({ + "apiVersion": "v1", "kind": "Service", + "metadata": {"name": "gateway-svc", "namespace": "edge"}, + "spec": {"type": "ClusterIP", "selector": {"app": "gateway"}}, + "status": {"loadBalancer": {"ingress": [{"ip": "198.51.100.9"}]}} + })); + let gateway_pod = pod(json!({ + "apiVersion": "v1", "kind": "Pod", + "metadata": {"name": "gateway", "namespace": "edge", "labels": {"app": "gateway"}}, + "spec": {"containers": [{"name": "c", "image": "gateway:1"}]} + })); + + let inner_class = ingress_class(json!({ + "apiVersion": "networking.k8s.io/v1", "kind": "IngressClass", + "metadata": {"name": "inner"}, + "spec": {"controller": "vendor/inner"} + })); + let inner_ingress = ingress(json!({ + "apiVersion": "networking.k8s.io/v1", "kind": "Ingress", + "metadata": {"name": "inner-ingress", "namespace": "edge"}, + "spec": { + "ingressClassName": "inner", + "rules": [{"http": {"paths": [ + {"path": "/", "pathType": "Prefix", "backend": {"service": {"name": "app-svc", "port": {"number": 80}}}} + ]}}] + }, + "status": {"loadBalancer": {"ingress": [{"ip": "198.51.100.9"}]}} + })); + let app_svc = service(json!({ + "apiVersion": "v1", "kind": "Service", + "metadata": {"name": "app-svc", "namespace": "edge"}, + "spec": {"type": "ClusterIP", "selector": {"app": "inner-app"}} + })); + let app_pod = pod(json!({ + "apiVersion": "v1", "kind": "Pod", + "metadata": {"name": "inner-app", "namespace": "edge", "labels": {"app": "inner-app"}}, + "spec": {"containers": [{"name": "c", "image": "app:1"}]} + })); + + let snap = Snapshot { + pods: vec![pod(outer_extras[0].clone()), gateway_pod, app_pod], + services: vec![outer_svc, gateway_svc, app_svc], + ingresses: vec![outer_ingress, inner_ingress], + ingress_classes: vec![ingress_class(outer_extras[1].clone()), inner_class], + ..Default::default() + }; + + let graph = build_graph(&snap, &default_adapters()); + assert_eq!( + workload_exposure(&graph, "edge", "gateway"), + Exposure::Internet + ); + // The second hop only promotes because the fixpoint re-checked gateway's + // freshly-promoted exposure — proving the chain actually composed. + assert_eq!( + workload_exposure(&graph, "edge", "inner-app"), + Exposure::Internet + ); +} + +/// With no in-cluster Ingress at all, the ADR-0012 off-cluster declaration (e.g. +/// cloudflared) is untouched — this adapter contributes nothing and never overrides +/// or interferes with the declared annotation. +#[test] +fn cloudflared_annotation_still_wins_with_no_in_cluster_ingress() { + let tunnel_pod = pod(json!({ + "apiVersion": "v1", "kind": "Pod", + "metadata": {"name": "tunneled", "namespace": "app", "labels": {"app": "tunneled"}}, + "spec": {"containers": [{"name": "c", "image": "tunneled:1"}]} + })); + let tunnel_svc = service(json!({ + "apiVersion": "v1", "kind": "Service", + "metadata": { + "name": "tunneled-svc", "namespace": "app", + "annotations": {"protector.jeffl.es/exposure": "internet"} + }, + "spec": {"type": "ClusterIP", "selector": {"app": "tunneled"}} + })); + + let snap = Snapshot { + pods: vec![tunnel_pod], + services: vec![tunnel_svc], + ..Default::default() + }; + + let graph = build_graph(&snap, &default_adapters()); + assert_eq!( + workload_exposure(&graph, "app", "tunneled"), + Exposure::Internet + ); +} diff --git a/engine/src/engine/observe/adapter/mod.rs b/engine/src/engine/observe/adapter/mod.rs index e36ea9a5..b7287402 100644 --- a/engine/src/engine/observe/adapter/mod.rs +++ b/engine/src/engine/observe/adapter/mod.rs @@ -43,6 +43,7 @@ mod enrich; mod escape; mod exposure; mod findings; +mod ingress_exposure; mod linkerd; mod network; mod rbac; @@ -55,6 +56,7 @@ pub use self::enrich::{CveReachabilityAdapter, RuntimeAdapter, VulnerabilityAdap pub use self::escape::HostEscapeAdapter; pub use self::exposure::ExposureAdapter; pub use self::findings::{ConfigAuditAdapter, ExposedSecretAdapter, RbacAssessmentAdapter}; +pub use self::ingress_exposure::IngressExposureAdapter; pub use self::linkerd::LinkerdReachabilityAdapter; pub use self::network::ReachabilityAdapter; pub use self::rbac::PrivilegeAdapter; @@ -183,6 +185,11 @@ pub fn default_adapters() -> Vec> { // Fact-enrichment adapters run last: they read-modify nodes the structural // adapters already created. Box::new(ExposureAdapter), + // Route-transitive internet exposure (ADR-0038): promotes a route-forwarded + // backend to Exposure::Internet when a live internet-exposed controller + // actually serves the route. Must run after ExposureAdapter — it reads the + // controller workload's exposure fact that adapter just computed. + Box::new(IngressExposureAdapter), Box::new(VulnerabilityAdapter), // The other trivy-operator report kinds (JEF-244): exposed secrets onto Images, // config-audit + RBAC-assessment findings onto Workloads. Enrich existing nodes, diff --git a/engine/src/engine/observe/mod.rs b/engine/src/engine/observe/mod.rs index 3a538959..01d2f7af 100644 --- a/engine/src/engine/observe/mod.rs +++ b/engine/src/engine/observe/mod.rs @@ -31,7 +31,7 @@ pub mod trivy_rbac; pub mod trivy_secret; use k8s_openapi::api::core::v1::{Pod, Secret, Service}; -use k8s_openapi::api::networking::v1::NetworkPolicy; +use k8s_openapi::api::networking::v1::{Ingress, IngressClass, NetworkPolicy}; use k8s_openapi::api::rbac::v1::{ClusterRole, ClusterRoleBinding, Role, RoleBinding}; use kube::Api; use kube::api::ListParams; @@ -239,6 +239,14 @@ pub struct Snapshot { pub role_bindings: Vec, pub cluster_roles: Vec, pub cluster_role_bindings: Vec, + /// L7 routes (ADR-0038): the declared host/path→backend-Service rules the + /// [`adapter::IngressExposureAdapter`] walks to make internet-exposure follow + /// declared routing rather than stopping at the controller. + pub ingresses: Vec, + /// The `IngressClass` objects `ingresses` reference by `spec.ingressClassName` + /// (ADR-0038) — resolving the class is how the adapter confirms a route names a + /// real, live controller rather than an orphaned/typo'd class. + pub ingress_classes: Vec, /// Vulnerability findings per image (Vulnerability port). Populated from a /// scanner; see `observe`'s note on the live source. pub image_vulns: Vec, @@ -286,6 +294,8 @@ impl Snapshot { role_bindings, cluster_roles, cluster_role_bindings, + ingresses, + ingress_classes, image_vulns, trivy_findings, linkerd, @@ -346,6 +356,17 @@ impl Snapshot { .items, ) }, + // ADR-0038: the declared L7 routes and the classes they name, the + // IngressExposureAdapter's raw material. + async { anyhow::Ok(Api::::all(client.clone()).list(&lp).await?.items) }, + async { + anyhow::Ok( + Api::::all(client.clone()) + .list(&lp) + .await? + .items, + ) + }, async { anyhow::Ok( list_parsed( @@ -386,6 +407,8 @@ impl Snapshot { role_bindings, cluster_roles, cluster_role_bindings, + ingresses, + ingress_classes, image_vulns, image_secrets, config_audits, diff --git a/engine/src/engine/run_loop.rs b/engine/src/engine/run_loop.rs index cec7d7e5..a8e3ba87 100644 --- a/engine/src/engine/run_loop.rs +++ b/engine/src/engine/run_loop.rs @@ -378,7 +378,7 @@ pub async fn run_watch( ) -> anyhow::Result<()> { use futures::stream::StreamExt; use k8s_openapi::api::core::v1::{Pod, Secret, Service}; - use k8s_openapi::api::networking::v1::NetworkPolicy; + use k8s_openapi::api::networking::v1::{Ingress, IngressClass, NetworkPolicy}; use k8s_openapi::api::rbac::v1::{ClusterRole, ClusterRoleBinding, Role, RoleBinding}; use kube::Api; use kube::core::PartialObjectMeta; @@ -703,6 +703,10 @@ pub async fn run_watch( let (rolebindings, rolebindings_w) = reflector::store::(); let (clusterroles, clusterroles_w) = reflector::store::(); let (clusterrolebindings, clusterrolebindings_w) = reflector::store::(); + // The declared L7 routes and the classes they name (ADR-0038) — the + // IngressExposureAdapter's raw material. + let (ingresses, ingresses_w) = reflector::store::(); + let (ingress_classes, ingress_classes_w) = reflector::store::(); let cfg = watcher::Config::default(); // CRITICAL: each reflector runs in its OWN task so its Store stays current no @@ -742,6 +746,8 @@ pub async fn run_watch( spawn_reflector!(rolebindings_w, RoleBinding); spawn_reflector!(clusterroles_w, ClusterRole); spawn_reflector!(clusterrolebindings_w, ClusterRoleBinding); + spawn_reflector!(ingresses_w, Ingress); + spawn_reflector!(ingress_classes_w, IngressClass); tracing::info!("engine: watching cluster (event-driven)"); loop { @@ -784,6 +790,13 @@ pub async fn run_watch( .iter() .map(|r| (**r).clone()) .collect(), + // The declared L7 routes and the classes they name (ADR-0038). + ingresses: ingresses.state().iter().map(|i| (**i).clone()).collect(), + ingress_classes: ingress_classes + .state() + .iter() + .map(|c| (**c).clone()) + .collect(), // Vulnerabilities are listed best-effort on each pass (cheap, only when // something changed), then enriched with KEV exploit intel and EPSS // exploit-prediction scores. Runtime events are the live, TTL'd behavioral signals. From 4d1389b75081a3d41e7a8b69d352153b420ab72e Mon Sep 17 00:00:00 2001 From: Jeff Larson Date: Sat, 1 Aug 2026 13:41:40 -0700 Subject: [PATCH 2/2] fix(engine): degrade gracefully when Ingress RBAC/API is forbidden or absent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Ingress/IngressClass watch is the first ADR-0038 grant that can legitimately be missing (an older render, or the forked cluster chart before its RBAC hand-port lands) — every other watched type is always granted. A 403/404 hit kube-runtime's default retry-forever watch loop, which spammed "watch error ... forbidden" and starved the rest of the engine loop, taking the whole proof loop down with it (e2e: "structural chain web->session-key never proved within 300s"). Add observe::ingress_availability: a shared classifier for the Forbidden/ absent-API case and a log-once guard. Both the watch reflector (a one-time preflight LIST decides whether to start the persistent watch at all) and Snapshot::observe's initial LIST now degrade to an empty ingresses/ ingress_classes pair instead of retrying indefinitely or failing the whole call — IngressExposureAdapter already no-ops on an empty list (the existing under-promote fail direction), so this never touches the proof loop's correctness, only its resilience to a missing grant. Any other error (a transient blip) still retries exactly like every other watched type. Also grants the same networking.k8s.io ingresses/ingressclasses RBAC in scripts/e2e.sh (a hand-maintained mirror of the chart's ClusterRole, separate from charts/protector/templates/clusterrole.yaml which the prior commit already updated) so the e2e scenario exercises route-transitive exposure end-to-end rather than just degrading. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP --- .../observe/adapter/ingress_exposure/tests.rs | 43 +++++++ .../engine/observe/ingress_availability.rs | 106 ++++++++++++++++++ engine/src/engine/observe/mod.rs | 49 ++++++-- engine/src/engine/run_loop.rs | 43 ++++++- scripts/e2e.sh | 6 + 5 files changed, 233 insertions(+), 14 deletions(-) create mode 100644 engine/src/engine/observe/ingress_availability.rs diff --git a/engine/src/engine/observe/adapter/ingress_exposure/tests.rs b/engine/src/engine/observe/adapter/ingress_exposure/tests.rs index 3c7ea61a..598868e9 100644 --- a/engine/src/engine/observe/adapter/ingress_exposure/tests.rs +++ b/engine/src/engine/observe/adapter/ingress_exposure/tests.rs @@ -327,3 +327,46 @@ fn cloudflared_annotation_still_wins_with_no_in_cluster_ingress() { Exposure::Internet ); } + +/// A regression for the RBAC/API gap `observe::ingress_availability` degrades +/// through: when the Ingress/IngressClass watch is Forbidden or the API is absent, +/// `run_watch`/`Snapshot::observe` degrade to an empty `ingresses`/`ingress_classes` +/// pair (never abort the snapshot). At the graph layer that's indistinguishable from +/// a cluster with no Ingress objects at all — this proves an entirely unrelated, +/// pre-existing structural chain (a plain workload mounting a secret directly, no +/// exposure/Ingress involved whatsoever) still proves cleanly through `build_graph` +/// + `prove` with that empty pair, exactly the assertion the e2e regression checked. +#[test] +fn degraded_ingress_availability_does_not_disturb_an_unrelated_structural_chain() { + let web_pod = pod(json!({ + "apiVersion": "v1", "kind": "Pod", + "metadata": {"name": "web", "namespace": "app", "labels": {"app": "web"}}, + "spec": { + "containers": [{ + "name": "web", "image": "web:1", + "envFrom": [{"secretRef": {"name": "session-key"}}] + }] + } + })); + let snap = Snapshot { + pods: vec![web_pod], + secrets: vec![crate::engine::observe::SecretMeta { + namespace: "app".into(), + name: "session-key".into(), + }], + // The exact state degraded observation produces: no Ingress data at all. + ingresses: Vec::new(), + ingress_classes: Vec::new(), + ..Default::default() + }; + + let graph = build_graph(&snap, &default_adapters()); + let chains = prove(&graph); + assert!( + chains + .iter() + .any(|c| c.entry.0 == "workload/app/Pod/web" + && c.objective.0 == "secret/app/session-key"), + "web → session-key must still prove with a degraded/empty Ingress snapshot" + ); +} diff --git a/engine/src/engine/observe/ingress_availability.rs b/engine/src/engine/observe/ingress_availability.rs new file mode 100644 index 00000000..12c1991a --- /dev/null +++ b/engine/src/engine/observe/ingress_availability.rs @@ -0,0 +1,106 @@ +//! Graceful degradation for the Ingress/IngressClass RBAC ADR-0038 needs +//! (`networking.k8s.io: [ingresses, ingressclasses]`, `get/list/watch`). +//! +//! Every other watched type in [`Snapshot`](super::Snapshot) is always granted, so +//! [`run_loop::run_watch`](crate::engine::run_loop::run_watch) never had to consider +//! a permission gap on the resources it watches. The Ingress grant is the first +//! that can legitimately be missing — an operator on an older chart render, or the +//! forked cluster chart before its RBAC is hand-ported (ADR-0038's rollout note) — +//! so both the watch reflector and [`Snapshot::observe`](super::Snapshot::observe)'s +//! one-shot list must tolerate it: log once, treat the resource as absent, and let +//! [`IngressExposureAdapter`](super::adapter::IngressExposureAdapter) no-op on the +//! empty list (its existing under-promote fail direction, ADR-0038) — never abort +//! the snapshot or spin retrying a permission that isn't coming back without a +//! restart. + +use std::sync::atomic::{AtomicBool, Ordering}; + +/// Whether `error` means the Ingress/IngressClass API is unavailable to this +/// ServiceAccount: RBAC not granted (403 Forbidden) or the resource/API group isn't +/// registered on this cluster (404 Not Found) — the two gaps ADR-0038 must degrade +/// through rather than fail on. Checked by raw HTTP status rather than +/// `Status::is_forbidden`/`is_not_found` — those only fall back to the status code +/// when `reason` is unset AND not one of the well-known reason strings, so they'd +/// miss a 403/404 whose `reason` is absent or non-standard (an odd proxy/webhook in +/// front of the apiserver). Any other error (a transient network blip, a 5xx) is NOT +/// classified here, so callers keep retrying it exactly like every other watched +/// type. +pub(crate) fn ingress_api_unavailable(error: &kube::Error) -> bool { + matches!(error, kube::Error::Api(status) if status.code == 403 || status.code == 404) +} + +/// Runs a closure at most once, ever, for the life of the value — the log-once +/// guard that keeps a standing RBAC/API gap from re-announcing itself on every +/// retry (the tight-loop 403 spam ADR-0038's rollout must not reintroduce). +pub(crate) struct LogOnce(AtomicBool); + +impl LogOnce { + pub(crate) const fn new() -> Self { + Self(AtomicBool::new(false)) + } + + /// Runs `f` the first time this is called; every later call is a no-op. + pub(crate) fn call(&self, f: impl FnOnce()) { + if self + .0 + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_ok() + { + f(); + } + } +} + +/// The single, process-lifetime log-once guard for the "route-transitive exposure +/// disabled" warning — shared by the watch reflector and the one-shot list path so +/// the operator sees it exactly once no matter which path hits the gap first. +pub(crate) static INGRESS_UNAVAILABLE_LOGGED: LogOnce = LogOnce::new(); + +/// Logs the standing warning exactly once per process (see +/// [`INGRESS_UNAVAILABLE_LOGGED`]). +pub(crate) fn warn_ingress_unavailable_once() { + INGRESS_UNAVAILABLE_LOGGED.call(|| { + tracing::warn!( + "ingresses/ingressclasses (networking.k8s.io) is forbidden or absent for this \ + ServiceAccount — route-transitive internet exposure (ADR-0038) is disabled; \ + grant get/list/watch on networking.k8s.io ingresses/ingressclasses to enable it. \ + The engine's other observation and the proof loop continue normally." + ); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_403_and_a_404_are_unavailable_but_nothing_else_is() { + let forbidden = kube::Error::Api(Box::new(kube::core::Status { + code: 403, + ..Default::default() + })); + let not_found = kube::Error::Api(Box::new(kube::core::Status { + code: 404, + ..Default::default() + })); + let server_error = kube::Error::Api(Box::new(kube::core::Status { + code: 500, + ..Default::default() + })); + assert!(ingress_api_unavailable(&forbidden)); + assert!(ingress_api_unavailable(¬_found)); + // A 5xx (or any other status) is a transient-looking failure, not a standing + // permission/API gap — callers must keep retrying it, not silently degrade. + assert!(!ingress_api_unavailable(&server_error)); + } + + #[test] + fn log_once_runs_the_closure_exactly_once_across_repeated_calls() { + let guard = LogOnce::new(); + let mut runs = 0; + for _ in 0..5 { + guard.call(|| runs += 1); + } + assert_eq!(runs, 1); + } +} diff --git a/engine/src/engine/observe/mod.rs b/engine/src/engine/observe/mod.rs index 01d2f7af..1f6af99d 100644 --- a/engine/src/engine/observe/mod.rs +++ b/engine/src/engine/observe/mod.rs @@ -21,6 +21,7 @@ pub mod feed_reload; pub mod health; pub mod host_credential_class; pub mod ingest_guard; +pub(crate) mod ingress_availability; pub mod ip_index; pub mod linkerd; pub mod peer_class; @@ -294,8 +295,7 @@ impl Snapshot { role_bindings, cluster_roles, cluster_role_bindings, - ingresses, - ingress_classes, + ingress_routes, image_vulns, trivy_findings, linkerd, @@ -357,16 +357,10 @@ impl Snapshot { ) }, // ADR-0038: the declared L7 routes and the classes they name, the - // IngressExposureAdapter's raw material. - async { anyhow::Ok(Api::::all(client.clone()).list(&lp).await?.items) }, - async { - anyhow::Ok( - Api::::all(client.clone()) - .list(&lp) - .await? - .items, - ) - }, + // IngressExposureAdapter's raw material — degrades to empty rather than + // failing the whole observe() when the RBAC/API isn't there (see + // `list_ingress_routes`). + async { list_ingress_routes(&client, &lp).await }, async { anyhow::Ok( list_parsed( @@ -384,6 +378,7 @@ impl Snapshot { )?; let (image_secrets, config_audits, rbac_assessments) = trivy_findings; let (linkerd_servers, linkerd_authz_policies, linkerd_mtls_auths) = linkerd; + let (ingresses, ingress_classes) = ingress_routes; // Runtime events come from a runtime sensor (the first-party eBPF agent, or any // sensor via the behavioral port) — typically a stream, not a list. Wiring that @@ -422,6 +417,36 @@ impl Snapshot { } } +/// Lists Ingress + IngressClass objects (ADR-0038). A Forbidden/absent-API response +/// (the RBAC gap [`ingress_availability`] documents — e.g. the forked cluster +/// chart's RBAC hand-port not having landed yet) degrades to an empty pair, logged +/// once, rather than failing the whole [`Snapshot::observe`] call: every other list +/// here is always-granted, but this one legitimately might not be, and a missing +/// route-transitive-exposure signal must never take the rest of observation down +/// with it. Any other error still fails the caller, exactly like every other list. +async fn list_ingress_routes( + client: &kube::Client, + lp: &ListParams, +) -> anyhow::Result<(Vec, Vec)> { + async fn list_or_degrade(client: &kube::Client, lp: &ListParams) -> anyhow::Result> + where + K: kube::Resource + Clone + std::fmt::Debug + serde::de::DeserializeOwned, + { + match Api::::all(client.clone()).list(lp).await { + Ok(list) => Ok(list.items), + Err(error) if ingress_availability::ingress_api_unavailable(&error) => { + ingress_availability::warn_ingress_unavailable_once(); + Ok(Vec::new()) + } + Err(error) => Err(error.into()), + } + } + Ok(( + list_or_degrade::(client, lp).await?, + list_or_degrade::(client, lp).await?, + )) +} + /// Best-effort list of the other three trivy-operator report kinds (JEF-244): /// `ExposedSecretReport`, `ConfigAuditReport`, and `RbacAssessmentReport`. Each is empty /// when its CRD isn't installed or is unreadable, so the engine degrades to no data for that diff --git a/engine/src/engine/run_loop.rs b/engine/src/engine/run_loop.rs index a8e3ba87..99767eb9 100644 --- a/engine/src/engine/run_loop.rs +++ b/engine/src/engine/run_loop.rs @@ -28,6 +28,32 @@ fn restore_admission_log( restored } +/// Whether `run_watch` should start a persistent watch for `K` (ADR-0038's +/// Ingress/IngressClass preflight — see the call site). A single LIST decides it: a +/// Forbidden/absent-API response logs once and answers `false` (never watched, so +/// the store stays permanently empty rather than retry-storming a permission that +/// isn't coming back without a restart); any other error is treated as transient and +/// answers `true`, same as every always-granted type. +async fn ingress_rbac_available(client: &kube::Client) -> bool +where + K: kube::Resource + Clone + std::fmt::Debug + serde::de::DeserializeOwned, +{ + match kube::Api::::all(client.clone()) + .list(&kube::api::ListParams::default()) + .await + { + Ok(_) => true, + Err(error) if observe::ingress_availability::ingress_api_unavailable(&error) => { + observe::ingress_availability::warn_ingress_unavailable_once(); + false + } + Err(error) => { + tracing::warn!(%error, "initial Ingress/IngressClass list failed; watching anyway"); + true + } + } +} + /// Build the dashboard's app-level OIDC gate from the environment (ADR-0030 / JEF-487): the /// fail-closed access control that closes the port-forward hole. Returns the `(enforcer, auth-mode)` /// to thread into the dashboard: @@ -746,8 +772,21 @@ pub async fn run_watch( spawn_reflector!(rolebindings_w, RoleBinding); spawn_reflector!(clusterroles_w, ClusterRole); spawn_reflector!(clusterrolebindings_w, ClusterRoleBinding); - spawn_reflector!(ingresses_w, Ingress); - spawn_reflector!(ingress_classes_w, IngressClass); + // ADR-0038: unlike every type above (always granted), the Ingress/IngressClass + // RBAC can legitimately be missing (an older chart render, or the forked cluster + // chart before its RBAC hand-port lands — see the ADR's rollout note). Preflight + // with a single LIST before committing to a persistent watch: a Forbidden/ + // absent-API response degrades to "no route-transitive exposure" (the store + // stays empty forever) instead of entering kube-runtime's default retry-forever + // loop against a permission that isn't coming back without a restart — that + // retry loop is what starved the rest of the engine loop. Any other error (a + // transient blip) still starts the watch, exactly like every other type. + if ingress_rbac_available::(&client).await { + spawn_reflector!(ingresses_w, Ingress); + } + if ingress_rbac_available::(&client).await { + spawn_reflector!(ingress_classes_w, IngressClass); + } tracing::info!("engine: watching cluster (event-driven)"); loop { diff --git a/scripts/e2e.sh b/scripts/e2e.sh index 4c3d4624..04de6d68 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -353,6 +353,12 @@ rules: - apiGroups: ["networking.k8s.io"] resources: ["networkpolicies"] verbs: ["get", "list", "watch"] + # ADR-0038: the IngressExposureAdapter's raw material — mirrors + # charts/protector/templates/clusterrole.yaml so the observer actually functions + # (not just degrades) in e2e. + - apiGroups: ["networking.k8s.io"] + resources: ["ingresses", "ingressclasses"] + verbs: ["get", "list", "watch"] - apiGroups: ["rbac.authorization.k8s.io"] resources: ["roles", "rolebindings", "clusterroles", "clusterrolebindings"] verbs: ["get", "list", "watch"]