Skip to content

feat(engine): ingress exposure observer — route-transitive Exposure::Internet through declared L7 routes (ADR-0038) - #313

Merged
thejefflarson merged 2 commits into
mainfrom
thejefflarson/jef-697-ingress-exposure-observer-route-transitive-exposureinternet
Aug 1, 2026
Merged

feat(engine): ingress exposure observer — route-transitive Exposure::Internet through declared L7 routes (ADR-0038)#313
thejefflarson merged 2 commits into
mainfrom
thejefflarson/jef-697-ingress-exposure-observer-route-transitive-exposureinternet

Conversation

@thejefflarson

Copy link
Copy Markdown
Owner

Summary

Implements ADR-0038: makes internet-exposure follow declared L7 routes so a
route-forwarded backend of a live internet-exposed Ingress controller becomes
a normal Exposure::Internet entry, reusing the entire existing edge-CVE
promotion lane (proof, prompt, guards, menu, ledger, bench) with zero new
evidence class, model tag, or graph relation
.

  • New IngressExposureAdapter (engine/src/engine/observe/adapter/ingress_exposure.rs),
    registered right after ExposureAdapter in default_adapters().
  • D1 — controller-anchoring. Kubernetes has no object linking an
    IngressClass to the workload implementing its spec.controller string.
    Rather than guess via a naming/label convention (reopening the
    over-promotion hazard the ADR calls out), the adapter matches a live
    controller's Ingress.status.loadBalancer address against its own fronting
    Service's status.loadBalancer address — the one deterministic,
    controller-agnostic signal the K8s API actually provides. An Ingress whose
    controller never claimed it with a live address doesn't propagate
    (under-promote fail direction, per ADR-0012's precedent).
  • D2 — bounded fixpoint. Promotion re-scans every Ingress until a pass
    makes no new promotion, bounded by graph.node_count(), so a promoted
    backend can anchor further routes it serves ("chains compose") with no
    risk of an unbounded loop.
  • Plumbs Snapshot.ingresses / Snapshot.ingress_classes through both the
    poll observer (Snapshot::observe) and the watch reflectors
    (run_loop.rs), and grants read-only networking.k8s.io
    ingresses/ingressclasses RBAC in the chart.
  • Flips docs/adr/0038-transitive-internet-exposure-l7-routes.md to
    Accepted, with a D1/D2 addendum recording the decisions above.

Both decisions are recorded in the ADR addendum since ADR-0038 explicitly
left the controller-anchoring mechanism to the implementation.

Required human follow-up

The forked cluster chart (../cluster/charts/protector, a separate infra
repo) needs the same ingresses/ingressclasses RBAC + watch additions
hand-ported
— that fork only auto-bumps the image tag; chart template
changes here do not propagate to it. This PR does not touch that repo.

Test plan

  • New graph-level tests in engine/src/engine/observe/adapter/ingress_exposure/tests.rs:
    • route_forwarded_backend_of_live_controller_becomes_a_proven_entry
      drives the full reason::proof::prove walk and asserts an
      EXPLOIT_PUBLIC_FACING foothold on the route-forwarded backend.
    • orphan_ingress_does_not_promote — both orphan shapes (unresolved class;
      resolvable class with no live address match) stay below Internet.
    • chains_compose_one_further_hop — a backend promoted in round 1 anchors
      a second Ingress in round 2, proving the fixpoint re-derives against
      current graph state.
    • cloudflared_annotation_still_wins_with_no_in_cluster_ingress — the
      ADR-0012 declared annotation is untouched when there's no Ingress at all.
  • cargo fmt --all -- --check clean
  • cargo clippy --all-targets -- -D warnings clean
  • cargo nextest run --workspace — 1162 passed, 2 skipped (includes the
    file-size guard)
  • Zero diff to reason/proof/, the adjudication prompt, guards, or
    menu (git diff origin/main -- engine/src/engine/reason/ is empty) —
    existing suites pass unedited, confirming the seam
    (Node::Workload + w.exposure == Exposure::Internet) is the entire
    integration surface.
  • /soundcheck:pr-review — no Critical/High findings (RBAC additions
    are read-only and narrowly scoped; new logic operates only on
    already-trusted, RBAC-gated cluster API data, same trust boundary as
    every other observe-layer adapter).
  • /simplify pass — one efficiency fix applied (hoisted a per-selector-key
    label-map clone in selected_pods to once per pod); no other findings.

Closes JEF-697

🤖 Generated with Claude Code

https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP

…gress routes (ADR-0038)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP
@thejefflarson
thejefflarson enabled auto-merge (squash) August 1, 2026 20:24
… absent

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP
@thejefflarson
thejefflarson merged commit 2a58f8b into main Aug 1, 2026
6 checks passed
@thejefflarson
thejefflarson deleted the thejefflarson/jef-697-ingress-exposure-observer-route-transitive-exposureinternet branch August 1, 2026 20:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant