Network hardening
Everything on this page is required for production deployment: the enrollment and request flows work with the documented configuration, and these controls are what make the same flows safe to rely on in production. The adapter only enforces the boundary if the network makes the adapter the only path. This page hardens the enforced traffic flow:
flowchart LR C["Caller"] --> G["Gateway / ingress"] G -->|"protected routes only"| A["trustplane-adapter"] A -->|"verified only"| U["Upstream API"] C -. "must be denied" .-> U C -. "must be denied" .-> CT["Control (direct)"]
Three properties must hold at the network layer, independently of anything the adapter verifies:
- The upstream accepts traffic only from the adapter. Any other path lets callers skip verification entirely.
- The adapter can reach only what it needs. Restricted egress limits what a compromised adapter (or a compromised bundle-refresh dependency) can touch.
- The gateway routes protected traffic only to the adapter, never directly to the upstream. A single leftover direct route defeats the whole boundary.
Remember the two-plane distinction: after enrollment, request authorization is possession of the derived private key plus key status and route policy — enrollment does not pin later requests to the original workload. Network controls like the ones on this page are exactly the “additional network controls” that limit where a stolen or exported derived key is usable. See OIDC JWKS enrollment runbook for the enrollment-plane view.
Prerequisites
Section titled “Prerequisites”- A running adapter deployment with a working caller → gateway/ingress → adapter → upstream path (see Deployment overview and Gateway integration patterns). Harden a working path; do not debug routing and policy at the same time.
- Kubernetes namespaces and pod labels for the adapter and the upstream. Every namespace and
label below is a placeholder — take the real values from your own manifests or Helm
values, for example
helm get values <release>orkubectl get pods -n <namespace> --show-labels. - A CNI that actually enforces
NetworkPolicy(see the caveat below). - Permission to create
NetworkPolicyobjects in both namespaces, and to run short-lived test pods for verification.
Placeholders used throughout — replace all of them:
| Placeholder | Meaning | Where the real value comes from |
|---|---|---|
<upstream-namespace> |
Namespace running the upstream API | Your upstream deployment manifests |
app: <upstream-api-label> |
Pod label selecting the upstream API pods | kubectl get pods -n <upstream-namespace> --show-labels |
<adapter-namespace> |
Namespace running trustplane-adapter |
Your Helm release namespace |
app.kubernetes.io/name: trustplane-adapter |
Adapter pod label | Rendered chart output (helm template) |
8080 |
Upstream application port | Upstream Service/container spec |
8081 |
Adapter listen port | adapter.port in your Helm values |
<control-endpoint-cidr> |
IP range for Control / bundle refresh | Your Control deployment’s published addresses |
<jwks-endpoint-cidr> |
IP range for configured JWKS/issuer endpoints | Your issuer’s published addresses |
<telemetry-endpoint-cidr> |
IP range for telemetry/audit sinks | Your observability platform |
Upstream: default-deny ingress, allow only the adapter
Section titled “Upstream: default-deny ingress, allow only the adapter”Required for production deployment. A NetworkPolicy that selects the upstream pods is an
allow-list: once any policy selects a pod for Ingress, everything not explicitly allowed is
denied. One policy therefore implements both default-deny and the single adapter allowance:
# All namespace names and labels below are PLACEHOLDERS — replace with your values.apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: upstream-allow-adapter-only namespace: <upstream-namespace> # placeholderspec: podSelector: matchLabels: app: <upstream-api-label> # placeholder: your upstream pod label policyTypes: - Ingress ingress: - from: - namespaceSelector: matchLabels: kubernetes.io/metadata.name: <adapter-namespace> # placeholder podSelector: matchLabels: app.kubernetes.io/name: trustplane-adapter # placeholder: adapter pod label ports: - protocol: TCP port: 8080 # placeholder: upstream application portExpected state after applying: the upstream Service still resolves cluster-wide, but only
adapter pods can complete a connection to it. Combining namespaceSelector and podSelector
in one from entry means “adapter-labeled pods in the adapter namespace” — do not split them
into two list items, which would mean “any pod in the adapter namespace OR any adapter-labeled
pod anywhere”.
Adapter: restricted egress
Section titled “Adapter: restricted egress”Required for production deployment. The adapter needs a short list of destinations and nothing else:
- the upstream API;
- cluster DNS;
- Control, for signed bundle refresh (see Bundle refresh and Control-signed bundles);
- the configured JWKS/issuer endpoints, when the deployment fetches issuer material at runtime;
- telemetry and audit dependencies, if the adapter ships events off-pod.
# All namespace names, labels, and CIDRs below are PLACEHOLDERS — replace with your values.apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: trustplane-adapter-restrict-egress namespace: <adapter-namespace> # placeholderspec: podSelector: matchLabels: app.kubernetes.io/name: trustplane-adapter # placeholder: adapter pod label policyTypes: - Egress egress: # Cluster DNS - to: - namespaceSelector: matchLabels: kubernetes.io/metadata.name: kube-system ports: - protocol: UDP port: 53 - protocol: TCP port: 53 # Upstream API - to: - namespaceSelector: matchLabels: kubernetes.io/metadata.name: <upstream-namespace> # placeholder podSelector: matchLabels: app: <upstream-api-label> # placeholder ports: - protocol: TCP port: 8080 # placeholder: upstream application port # Control / bundle refresh, JWKS/issuer endpoints, telemetry (HTTPS) - to: - ipBlock: cidr: <control-endpoint-cidr> # placeholder - ipBlock: cidr: <jwks-endpoint-cidr> # placeholder - ipBlock: cidr: <telemetry-endpoint-cidr> # placeholder ports: - protocol: TCP port: 443Gateway: route only to the adapter
Section titled “Gateway: route only to the adapter”Required for production deployment. Audit every route, listener, virtual host, and location/route
object on the gateway: protected traffic must target the adapter Service only. There must be
no route — including default/fallback routes, legacy routes kept “temporarily”, or debug
listeners — that reaches the upstream directly. The per-gateway shapes are in
Gateway integration patterns; this page is the network-level
backstop for them. Even with a correct gateway config, keep the upstream ingress policy above
in place: gateway configuration drifts, and the NetworkPolicy catches the drift.
For standing up the public hostname, certificate, and DNS in front of the gateway, see Gateway, certificates, and DNS.
CNI enforcement caveat
Section titled “CNI enforcement caveat”ALB target-type IP caveat
Section titled “ALB target-type IP caveat”Required for production deployment. With an AWS ALB using target-type: ip, the load balancer sends
traffic straight to pod IPs. NetworkPolicy governs in-cluster paths (pod-to-pod, and
what pods the ALB targets accept); ALB security groups and VPC routing/firewalling govern
external reachability. Both layers are needed:
NetworkPolicycannot stop an internet client from reaching the ALB — the ALB’s security groups and listener rules do that.- ALB security groups cannot stop an in-cluster pod from dialing the upstream — the
NetworkPolicydoes that.
Verify each layer with a check that exercises it; passing one layer says nothing about the other.
Verification
Section titled “Verification”Required for production deployment. Run all four checks. They use short-lived test pods
(kubectl run --rm) and read only HTTP status codes — no secrets, no response bodies. curl
prints 000 when the connection itself fails (blocked, timed out, or reset), which is the
“denied” signal.
# 1. Arbitrary pod → upstream (expect denied)kubectl run tp-netcheck --rm -i --restart=Never -n default \ --image=curlimages/curl -- \ curl -s -o /dev/null -w '%{http_code}\n' --max-time 5 \ http://<upstream-service>.<upstream-namespace>.svc.cluster.local:8080/
# 2. Adapter identity → upstream (expect allowed)kubectl run tp-netcheck-adapter --rm -i --restart=Never -n <adapter-namespace> \ --labels=app.kubernetes.io/name=trustplane-adapter \ --image=curlimages/curl -- \ curl -s -o /dev/null -w '%{http_code}\n' --max-time 5 \ http://<upstream-service>.<upstream-namespace>.svc.cluster.local:8080/
# 3. Gateway → adapter → upstream (expect the adapter to answer and deny the unsigned request)curl -s -o /dev/null -w '%{http_code}\n' --max-time 5 \ https://<gateway-hostname>/<protected-route>
# 4. Direct-to-Control bypass (expect denied)kubectl run tp-netcheck-control --rm -i --restart=Never -n default \ --image=curlimages/curl -- \ curl -s -o /dev/null -w '%{http_code}\n' --max-time 5 \ https://<control-internal-address>/Check 2 borrows the adapter’s network identity by carrying its pod label in its namespace —
that is the point of the check, and also why the pod must be short-lived (--rm) and why the
label values must exactly match the adapter’s.
| # | Check | Expected result | What it proves |
|---|---|---|---|
| 1 | Arbitrary pod → upstream | 000 (connection blocked/timed out) |
Default-deny ingress is enforced; callers cannot bypass the adapter in-cluster |
| 2 | Adapter → upstream | An HTTP status from the upstream (for example 200 on a health path, or 404 on /) |
The allow rule admits exactly the adapter identity; the data path still works |
| 3 | Gateway → adapter → upstream | 401 or 403 from the adapter |
The public path terminates at the adapter, which denies an unsigned request — verification is in the path |
| 4 | Arbitrary pod → Control directly | 000 (connection blocked/timed out) |
Control is reachable only through its fronting proxy, a precondition for the trusted-proxy section below |
Expected safe failure states: check 1 or 4 returning an HTTP status code means the deny is
not enforced — stop and fix the policy (or the CNI) before proceeding; nothing has been
broken, the boundary just is not up yet. Check 2 returning 000 means the allow rule’s
labels/namespace do not match the real adapter pods — the adapter itself is likely also cut
off, so fix or roll back the egress/ingress policy promptly. Check 3 returning 000 is a
gateway routing or DNS/TLS problem, not a policy success.
Control trusted-proxy configuration
Section titled “Control trusted-proxy configuration”Required for production deployment. Control derives the client address of each request from
forwarded headers (X-Forwarded-For and friends) only when the request arrives from a
configured trusted forwarded-address CIDR. Order matters:
- First block direct access to Control (verification check 4 above, plus the equivalent
external controls). While anything can reach Control directly, any client can write its own
X-Forwarded-For. - Only then configure the trusted forwarded-address CIDRs to the stable address range of the proxy/gateway tier that legitimately fronts Control.
Rules for the CIDR value:
- Never
0.0.0.0/0. That trusts forwarded headers from everyone, which is equivalent to letting every client choose its own identity. - Use a stable proxy identity or range — the gateway tier’s allocated subnet, NAT/egress range, or load-balancer range from your platform’s configuration.
- Do not use an observed ephemeral pod IP. A pod IP seen in logs today is reassigned to an arbitrary pod tomorrow; a trusted-proxy entry built from it either breaks silently on reschedule or, worse, ends up trusting an unrelated workload.
Why this matters: Control applies per-client rate limiting keyed on the derived client address (enrollment submission and polling are rate-limited per client — see Enrollment troubleshooting). A spoofable forwarded address lets one caller either evade its own limits or exhaust another client’s bucket, and it poisons the client addresses recorded in audit evidence. Trusted-proxy configuration is what makes “per-client” mean the client and not whatever the client claims.
Rollback boundary
Section titled “Rollback boundary”NetworkPolicy objects are additive and independent of the adapter, bundles, keys, and
Control state. Rolling back is deleting the specific policies:
kubectl delete networkpolicy upstream-allow-adapter-only -n <upstream-namespace>kubectl delete networkpolicy trustplane-adapter-restrict-egress -n <adapter-namespace>That restores the namespace’s previous (typically open) connectivity and touches nothing else: no bundle, key, enrollment, or Control-side change is involved, so there is nothing else to recover. The trusted-proxy CIDR configuration in Control is a separate control with its own change history — revert it in Control if the fronting proxy range changes. Note that deleting the upstream ingress policy re-opens the bypass path; treat that as an emergency measure with a ticket to restore, not a steady state.
Next steps
Section titled “Next steps”- Gateway, certificates, and DNS — stand up the public hostname, certificate, and DNS records in front of this hardened path.
- Gateway integration patterns — per-gateway routing shapes that keep the adapter as the only verifier.
- Bundle refresh — the Control egress dependency this page’s egress policy must keep open.
- OIDC JWKS enrollment runbook — the enrollment flow whose derived keys these network controls contain.