Network Architecture¶
This document covers the network topology of a deployed gateway: which components initiate which connections, how NetworkPolicy rules implement the isolation boundary, and how to validate that isolation is correctly enforced.
Component Connection Map¶
System namespace (gmc-system)
═════════════════════════════
GMC ──(1)──▶ K8s API Server (in-cluster) ─────────────┐
│
Tenant namespace │
════════════════ │
AGC ──(2)──▶ K8s API Server (via service CIDR) ───────┘
│
└─(3)──▶ Proxy ClusterIP Service ──(4)──▶ GitHub (external)
▲
Worker Pod ──(5)─────────┘
In the proxied topology described here — a gateway with an attached EgressProxy — all GitHub-bound traffic from both the AGC and worker pods is routed through the per-tenant egress proxy pool. Kubernetes API traffic from the AGC travels directly in-cluster and bypasses the proxy. (The v2 direct-egress, proxy-less mode routes GitHub traffic straight from the pods instead of through a proxy pool; see Proxy-less onboarding (direct egress).)
Connection Inventory¶
| # | Initiator | Destination | Protocol | In-cluster? | Via proxy? |
|---|---|---|---|---|---|
| 1 | GMC | K8s API server | HTTPS (443 / 6443) | Yes | No |
| 2 | AGC | K8s API server | HTTPS (443 / 6443) | Yes | No |
| 3 | AGC | Proxy ClusterIP Service | HTTPS CONNECT (8080) | Yes | — |
| 4 | Proxy pod | GitHub API endpoints (see below) | HTTPS (443) | No (egress) | — |
| 5 | Worker pod | Proxy ClusterIP Service | HTTPS CONNECT (8080) | Yes | — |
Connections (3) and (5) to the proxy are HTTPS, not plain HTTP. The GMC generates a per-tenant self-signed cert for the proxy at provisioning time and pins it into the AGC's trust store (W7 / M-5). This protects the AGC↔proxy hop from in-cluster eavesdropping or impersonation by any tenant whose pods can reach the Service ClusterIP.
The GMC also makes one additional outbound call: GET https://api.github.com/meta every 24 hours to refresh the GitHub IP ranges used in tenant NetworkPolicy egress rules. This call originates from the GMC's own egress path in the system namespace, not through any tenant's proxy pool.
GitHub Endpoints Reached via Proxy¶
| Endpoint | Used by | Purpose |
|---|---|---|
api.github.com |
AGC | GitHub App token exchange, rerun API |
*.actions.githubusercontent.com |
AGC | Broker API (GetMessage, AcquireJob, RenewJob) |
pipelines.actions.githubusercontent.com |
Worker pod | Twirp Results Service (live log streaming) |
objects.githubusercontent.com |
Worker pod | Action source downloads |
GitHub publishes its current IP ranges at https://api.github.com/meta under the actions key. The GMC uses this list to populate proxy pod NetworkPolicy egress rules and refreshes them every 24 hours. If spec.proxy.managedNetworkPolicy is false, operators are responsible for keeping egress rules current.
Per-tenant egress IP: the source-IP mechanism¶
The per-tenant proxy pool establishes a per-tenant choke point — all of a tenant's GitHub-bound traffic (AGC control plane + worker data plane) funnels through one identifiable set of proxy pods. That is a prerequisite for the per-tenant egress-IP claim, but it is not by itself sufficient: what source IP GitHub observes is decided one layer lower, by how the cluster masquerades (SNATs) pod egress. On a stock cloud cluster, proxy-pod egress SNATs to the node's shared cloud egress IP (GKE Cloud NAT, an EKS NAT gateway, an AKS outbound rule), which is shared across tenants on that node and not stable across proxy-pod reschedules.
Delivering a distinct, stable, per-tenant source IP at GitHub — the property that makes GitHub-side IP allowlisting, per-tenant audit attribution, incident containment, and the per-tenant kill-switch coherent — requires an explicit egress-IP mechanism underneath the proxy pool: Cilium Egress Gateway (per-tenant egress IP via a gateway node) or per-tenant cloud NAT (dedicated subnet/node-pool + reserved static IP). The mechanism binds a source IP to the proxy's choke point; it does not change the proxy pool or the NetworkPolicy rules below, which stay orthogonal and complementary. The comparison of the two mechanisms, the single-tenant-direct (dogfood) vs production multi-tenant topology, the cost model, and the live-validation results are specified in the per-tenant egress-IP reference architecture (Q243). That live validation has now landed: a real GMC-provisioned egress proxy pins to a single distinct, stable per-tenant Cloud NAT IP (live PASS 2026-07-13), so the distinct-per-tenant-egress-IP property is live-substantiated, not just designed. The dogfood cluster itself runs single-tenant-direct and does not exercise it.
NetworkPolicy Rules¶
The GMC creates three NetworkPolicy objects per tenant in the tenant namespace. The split (over a single combined policy) closes M-12 — worker pods inherit egress to the proxy and DNS only, not the Kubernetes API server. Only the AGC Deployment has API-server egress.
Policy 1: actions-gateway-workload — AGC and worker pods → proxy + DNS¶
Selects all "workload" pods (AGC and worker) by the actions-gateway/component: workload label. Allows egress to the proxy pods (port 8080) and DNS only. Denies all ingress.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: actions-gateway-workload
namespace: <tenant>
spec:
podSelector:
matchLabels:
actions-gateway/component: workload
policyTypes:
- Ingress
- Egress
ingress: [] # no ingress permitted
egress:
# DNS — needed for resolving the proxy Service name. Confined to cluster DNS,
# not "any resolver": an open port-53 rule is an unattributed exfiltration
# side-channel (Q105). Two OR'd peers cover both delivery paths: the kube-dns
# / CoreDNS pods in kube-system (direct path), and the link-local block
# 169.254.0.0/16 for NodeLocal DNSCache clusters where pods send DNS to a
# per-node hostNetwork cache (Q136). Link-local is non-routable, so it does
# not widen the exfil surface.
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
- ipBlock:
cidr: 169.254.0.0/16
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
# Proxy pods — selected by PodSelector, NOT the Service ClusterIP. kube-proxy
# DNATs ClusterIP → PodIP before NetworkPolicy enforcement, so an
# `ipBlock: <ClusterIP>/32` rule never matches actual packets and silently
# drops all proxy-bound traffic (the PR #59 trap). Selecting the proxy pods
# directly matches post-DNAT destinations and survives proxy pod churn from
# rolling updates and HPA scaling.
- to:
- podSelector:
matchLabels:
app: actions-gateway-proxy
ports:
- port: 8080
protocol: TCP
Policy 2: actions-gateway-controller — AGC → Kubernetes API server¶
Selects the AGC Deployment pods by app: actions-gateway-controller. Adds (additively) egress to the Kubernetes API server on ports 443 and 6443. Worker pods do not match this selector and so have no API-server egress.
Both apiserver ports are listed deliberately. NetworkPolicy port matches are evaluated against the post-DNAT destination port. Most production clusters expose the apiserver via the kubernetes Service at 443 → backends on 443, so a 443-only rule works. Kind (and any cluster where the apiserver Endpoints listen on 6443) translates ClusterIP 10.96.0.1:443 → <node-ip>:6443, and the policy evaluator sees 6443 — a 443-only rule silently drops every k8s API call. Allowing both ports keeps the rule precise (only apiserver-style ports) while working in both topologies. See docs/development/networkpolicy-port-matching.md for the diagnosis and a worked repro.
By default this rule has no destination restriction (any-dest): the post-DNAT apiserver IP is provider-specific and not predictable at deploy time, so a portable ipBlock cannot be hard-coded and any-dest is the secure default (the breadth is the §5.2 residual). Operators whose platform exposes a stable apiserver CIDR can opt in to scoping it: the GMC's --apiserver-cidrs flag (Helm value apiServerCIDRs) attaches an ipBlock peer per CIDR to this rule (ports unchanged) — an opt-in tightening, validated as CIDRs at GMC startup (Q145). Empty (the default) leaves the rule any-destination. See security-operations.md § Tightening AGC apiserver egress.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: actions-gateway-controller
namespace: <tenant>
spec:
podSelector:
matchLabels:
app: actions-gateway-controller
policyTypes:
- Egress
egress:
# DNS — confined to cluster DNS (kube-dns / CoreDNS in kube-system) plus the
# link-local block for NodeLocal DNSCache; see Q105/Q136.
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
- ipBlock:
cidr: 169.254.0.0/16
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
# Kubernetes API server — ports 443 and 6443 to any destination.
# Both ports are needed because NetworkPolicy enforcement evaluates
# post-DNAT: production clusters typically expose the apiserver on 443,
# kind translates Service:443 → node:6443. Allowing both works in both.
- ports:
- port: 443
protocol: TCP
- port: 6443
protocol: TCP
Policy 3: actions-gateway-proxy — Proxy pods → GitHub¶
Selects proxy pods by app: actions-gateway-proxy. Allows ingress only from "workload" pods on port 8080, and egress only to GitHub IP ranges (port 443) and DNS.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: actions-gateway-proxy
namespace: <tenant>
spec:
podSelector:
matchLabels:
app: actions-gateway-proxy
policyTypes:
- Ingress
- Egress
ingress:
# Only workload pods (AGC and workers) may CONNECT to the proxy
- from:
- podSelector:
matchLabels:
actions-gateway/component: workload
ports:
- port: 8080
protocol: TCP
egress:
# DNS — proxy resolves GitHub hostnames on behalf of clients. Confined to
# cluster DNS (kube-dns / CoreDNS in kube-system) plus the link-local block
# for NodeLocal DNSCache; kube-dns recurses upstream so external names still
# resolve, but the proxy cannot reach an arbitrary resolver — closing the
# open-DNS exfiltration side-channel (Q105/Q136).
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
- ipBlock:
cidr: 169.254.0.0/16
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
# GitHub IP ranges — populated from api.github.com/meta, refreshed every 24h
- to:
- ipBlock:
cidr: 192.30.252.0/22
- ipBlock:
cidr: 185.199.108.0/22
- ipBlock:
cidr: 140.82.112.0/20
# ... additional ranges from api.github.com/meta .actions
ports:
- port: 443
protocol: TCP
The actual IP ranges are fetched at provisioning time and refreshed every 24 hours. The example CIDRs above are illustrative; the authoritative list is at https://api.github.com/meta.
If spec.proxy.managedNetworkPolicy: false is set, the GMC omits the GitHub-CIDR egress rule from Policy 3 — operators using FQDN-based egress policies (Cilium, Calico) provide their own equivalent rule and the GMC stops fighting them on every IP range refresh.
CNI-native FQDN egress mode (opt-in, Q208, Q245)¶
On a DNS-aware policy CNI an operator can have the GMC express the proxy pool's GitHub allowlist by hostname instead of CIDR, removing the dependency on the 24h api.github.com/meta feed. The choice is split across two roles (Q245) so the tenant API stays stable as CNI/platform FQDN mechanisms proliferate:
- Tenant intent — a v2
EgressProxyselectsspec.egressPolicyMode: CIDR(default) — the standard NetworkPolicy + 24h IP-range reconcile described above. Works on every CNI, needs no operator backend.FQDN— "express my GitHub allowlist by hostname." The tenant does not name a CNI; the mechanism is the operator's choice.CiliumFQDN/CalicoFQDN— deprecated aliases that pin their namesake mechanism regardless of the operator backend (retained for backward compatibility; the admission webhook warns).- Operator backend — the GMC
--fqdn-policy-backendflag (none|cilium|calico|gke) resolves anFQDNintent to a concrete emitter, once per cluster: cilium→ aCiliumNetworkPolicy(cilium.io/v2) withtoFQDNsrules on TCP/443 plus a DNS-visibility rule so Cilium's DNS proxy learns the resolved IPs.calico→ a CalicoNetworkPolicy(projectcalico.org/v3) with the GitHub hostnames as destinationdomains.gke→ a GKE Dataplane V2FQDNNetworkPolicy(networking.gke.io/v1alpha1) withmatcheson TCP/443 (no DNS rule — DNS bypasses GKE FQDN enforcement and is carried by the base NetworkPolicy).none(default) → no backend; anFQDN-intentEgressProxyis rejected at admission, fail-closed and loud, never a silentDegraded.
The GitHub hostname set is the same across backends: api.github.com, github.com, codeload.github.com, objects.githubusercontent.com, *.actions.githubusercontent.com, *.blob.core.windows.net. In any FQDN mode the standard NetworkPolicy keeps its DNS + ingress rules but drops the GitHub-CIDR egress rule, and the IP-range reconcile skips the proxy. The CNI-native object is named <proxy>-proxy-fqdn and is owned by the EgressProxy for cascade GC. The posture stays fail-closed: because the standard NetworkPolicy still default-denies GitHub egress, a CNI that cannot enforce the FQDN policy leaves GitHub egress denied, never wide-open — so the opt-in cannot silently weaken the default. Backend prerequisites (the matching CRD installed / the platform feature enabled) are the operator's responsibility, documented in security-operations.md § Expressing GitHub egress by FQDN. FQDN mode is scoped to the v2 EgressProxy; the v1 proxy and v2 direct egress stay on the CIDR path.
The
gkebackend is additive-allow, not default-deny — the base NetworkPolicy is load-bearing. A GKEFQDNNetworkPolicycomposes with any NetworkPolicy on the same pod as a union (egress is allowed if it matches either), so on its own it denies nothing — it only adds an allow for the listed FQDNs. Cilium/Calico FQDN policies, by contrast, are self-default-denying. GAG's fail-closed guarantee forgketherefore depends on the base standard NetworkPolicy always being present (it default-denies GitHub egress with the CIDR rule dropped, and carries the DNS-only allow including the Q229 node-local-dns peer): theFQDNNetworkPolicyonly widens the union to permit GitHub, and if it is absent or unenforced GitHub egress stays denied by the base NP. Thegkereconcile branch is reached only when the base NP is managed, so a backend that opens egress without a default-deny base is never emitted.gke-backend enforcement is not yet live-validated on a real GKE cluster (deferred; see the Q245 plan).A managed "Cilium" platform usually does NOT accept the
ciliumbackend. The CRD test is literal:kubectl get crd ciliumnetworkpolicies.cilium.iomust succeed. GKE Dataplane V2's managed Cilium does NOT install that CRD (dropped since GKE 1.21.5-gke.1300) — there, use--fqdn-policy-backend=gke, notcilium. If a selected backend's CRD is absent theEgressProxygoesDegraded(no matches for kind …) and GitHub egress stays denied (fail-closed). The cluster-scoped backends still deferred (AKSCiliumClusterwideNetworkPolicyvia Advanced Container Networking Services, EKS DNS-basedClusterNetworkPolicyon Auto Mode) and OpenShift OVNEgressFirewalldnsNameneed a different ownership/GC model; tracked in the Q245 plan. Until a matching backend ships, use an in-cluster caching mirror (the recommended path for remote dependencies anyway) ormanagedNetworkPolicy: falseand layer your own policy.
Worker egress to allowlisted non-GitHub destinations (opt-in, Q242 G.1)¶
By default Policy 3 permits GitHub and nothing else, so jobs that fetch off-platform build dependencies (a Go module proxy, an internal artifact host, a cloud private-API endpoint) fail on egress. A platform admin can open a small, explicit set of non-GitHub destinations on a v2 EgressProxy — by DNS host suffix (spec.destinationFQDNs, e.g. proxy.golang.org) and by CIDR (spec.destinationCIDRs, e.g. an internal 10.x subnet or a Private-Google-Access range) — without forfeiting per-tenant egress-IP attribution or the DNS-exfil containment. The destinations widen two surfaces, derived from the one CR:
- Pod egress (the hard gate). CIDRs become native
ipBlockpeers on Policy 3 (port 443), present in everyegressPolicyMode— so a CIDR destination needs no DNS-aware CNI. Host suffixes are appended to the CNI-nativetoFQDNs/domainsset, so they require an FQDN mode (the CRD's CEL rule rejectsdestinationFQDNsinCIDRmode, since a standard NetworkPolicy can't express a hostname). GitHub stays implicit; the lists only add. - Proxy CONNECT check (defense-in-depth). The GMC injects the full permitted set — the GitHub hostnames plus the operator's destinations — into the proxy via
PROXY_ALLOWED_HOST_SUFFIXES/PROXY_ALLOWED_CIDRS, but only when the CR lists at least one extra destination; with none, the proxy stays transport-only (unchanged). The proxy matches a CONNECT host against the allowed suffixes, and for the CIDR path resolves the host and dials the validated IP (closing the DNS-rebinding window). A denied CONNECT returns403and incrementsactions_gateway_proxy_connect_denied_total.
Because the EgressProxy is tenant-authorable, what may be requested is gated by a platform-owned allowlist (--allowed-egress-fqdns suffix match / --allowed-egress-cidrs subnet containment, optionally augmented by a watched ConfigMap), enforced by an admission webhook — both empty ⇒ deny-all-non-GitHub (the secure default). Operators should prefer an in-cluster caching mirror for remote third-party dependencies and reserve the allowlist for what a mirror can't proxy; see security-operations.md § Worker egress destinations and the threat row in §5.2.
DNS Resolution¶
All in-cluster service discovery uses Kubernetes DNS (kube-dns / CoreDNS). The proxy pool is reachable from the AGC and worker pods via the ClusterIP Service name: actions-gateway-proxy.<namespace>.svc.cluster.local. The NO_PROXY env var includes kubernetes.default.svc.cluster.local and the cluster service CIDR so that Kubernetes API calls are never routed through the egress proxy.
External DNS resolution (for GitHub hostnames) is performed by the proxy pods themselves, not by the AGC or worker pods — the AGC and workers connect to the proxy using CONNECT <hostname>:<port> and the proxy resolves the hostname on their behalf. This means the proxy pods must have egress access to the cluster's DNS resolver in addition to GitHub's IP ranges.
DNS egress on all three policies is confined to cluster DNS rather than left open to any resolver (Q105). An unrestricted port-53 rule (to: []) would let any pod smuggle data to an attacker-controlled resolver — an unattributed side-channel that bypasses the per-tenant egress-IP attribution every other egress path enforces. Confining DNS to the in-cluster resolver keeps resolution on the attributable path: kube-dns recurses upstream on the pod's behalf, so external GitHub names still resolve while no pod can reach an arbitrary DNS server directly.
Each DNS rule allows two OR'd peers, covering the two ways a pod reaches cluster DNS:
- Direct path — the
kube-dns/CoreDNSService inkube-system, matched bynamespaceSelectoron the well-knownkubernetes.io/metadata.name: kube-systemlabel plus apodSelectoron the conventionalk8s-app: kube-dnslabel. - NodeLocal DNSCache path — the IPv4 link-local block
169.254.0.0/16, matched by anipBlock(Q136). On clusters running NodeLocal DNSCache (node-local-dns), pods send DNS to a link-local address (169.254.20.10by the kube-standard__PILLAR__LOCAL__DNS__convention) served by a per-nodehostNetworkDNSCache pod, which no pod/namespace selector can match. Allowing the whole link-local block is the simplest correct rule and preserves Q105's attribution property:169.254.0.0/16is non-routable and node-scoped, so it cannot reach an external resolver — the DNS-exfiltration channel Q105 closed stays closed.
Operators running a DNS service under a non-standard namespace or pod label must adjust the selector accordingly (or supply their own equivalent rule under spec.proxy.managedNetworkPolicy: false).
How to Validate Network Isolation¶
The AGC and proxy container images are distroless (no shell, no curl), so kubectl exec against the running pods can only inspect process state, not run probes. Instead, schedule a short-lived curlimages/curl pod and apply the same labels as the workload you want to simulate — Kubernetes selects NetworkPolicies by label, so a curl pod with actions-gateway/component: workload is governed by the same rules as the AGC and worker pods.
The negative checks below only hold on a CNI that enforces egress NetworkPolicy (Calico, Cilium, …). NetworkPolicy objects are inert without a CNI enforcer, and kind's default kindnet demonstrably does not drop egress for these cases — a "blocked" expectation will spuriously succeed there. Production clusters must run an egress-enforcing CNI for the workload isolation described in this document to exist at runtime. The workload-pod negatives below are automated as the Tier-A specs
E2E_GMC_TenantProvisioning_WorkloadEgressBlockedToNonProxyPodandE2E_GMC_TenantProvisioning_WorkerCannotReachK8sAPI, observed enforcing on a Calico kind cluster (make e2e-cluster KIND_CNI=calico) on 2026-06-11 — see the worker-egress-proxy plan.
Confirm a workload pod can reach GitHub via the proxy¶
kubectl run nettest-workload -n <namespace> --rm -it --restart=Never \
--image=curlimages/curl:latest \
--labels='actions-gateway/component=workload' \
--overrides='{"spec":{"automountServiceAccountToken":false}}' \
-- curl -x https://actions-gateway-proxy:8080 -sI https://api.github.com
# Expected: HTTP/2 200
Confirm a workload pod cannot reach GitHub directly (bypassing proxy)¶
kubectl run nettest-workload -n <namespace> --rm -it --restart=Never \
--image=curlimages/curl:latest \
--labels='actions-gateway/component=workload' \
--overrides='{"spec":{"automountServiceAccountToken":false}}' \
-- curl --noproxy '*' -sI --connect-timeout 5 https://api.github.com
# Expected: connection timeout (actions-gateway-workload NetworkPolicy blocks direct egress)
Confirm a worker-like pod cannot reach the Kubernetes API server¶
The actions-gateway-controller NetworkPolicy only matches pods labelled app=actions-gateway-controller, so worker pods (labelled actions-gateway/component=workload but not the AGC app label) have no API-server egress.
kubectl run nettest-worker -n <namespace> --rm -it --restart=Never \
--image=curlimages/curl:latest \
--labels='actions-gateway/component=workload' \
--overrides='{"spec":{"automountServiceAccountToken":false}}' \
-- curl --noproxy '*' -sI --connect-timeout 5 https://kubernetes.default.svc
# Expected: connection timeout
Confirm nothing can open a connection to a worker pod (ingress default-deny)¶
Worker pods run untrusted job code and accept no inbound by design — the workload NP declares policyTypes: [Ingress, Egress] with an empty ingress rule set, so all ingress is denied (Q128). Start a workload-labelled listener, then probe it from an unrelated pod: the connection must fail.
# Listener: a workload-labelled pod serving on 8000 (simulates a worker pod).
kubectl run nettest-listener -n <namespace> --restart=Never \
--image=python:3-alpine \
--labels='actions-gateway/component=workload' \
--overrides='{"spec":{"automountServiceAccountToken":false}}' \
-- python3 -m http.server 8000
kubectl wait -n <namespace> --for=condition=Ready pod/nettest-listener
# Probe from an unlabelled pod in the same namespace.
LISTENER_IP=$(kubectl get pod nettest-listener -n <namespace> -o jsonpath='{.status.podIP}')
kubectl run nettest-prober -n <namespace> --rm -it --restart=Never \
--image=curlimages/curl:latest \
--overrides='{"spec":{"automountServiceAccountToken":false}}' \
-- curl --noproxy '*' -sI --connect-timeout 5 "http://${LISTENER_IP}:8000"
# Expected: connection timeout (workload NP denies all ingress to worker pods)
kubectl delete pod nettest-listener -n <namespace>
Confirm a proxy-labelled pod can reach GitHub¶
kubectl run nettest-proxy -n <namespace> --rm -it --restart=Never \
--image=curlimages/curl:latest \
--labels='app=actions-gateway-proxy' \
--overrides='{"spec":{"automountServiceAccountToken":false}}' \
-- curl --noproxy '*' -sI --connect-timeout 5 https://api.github.com
# Expected: HTTP/2 200
Confirm a proxy-labelled pod cannot reach the K8s API server¶
kubectl run nettest-proxy -n <namespace> --rm -it --restart=Never \
--image=curlimages/curl:latest \
--labels='app=actions-gateway-proxy' \
--overrides='{"spec":{"automountServiceAccountToken":false}}' \
-- curl --noproxy '*' -sI --connect-timeout 5 https://kubernetes.default.svc
# Expected: connection timeout (proxy pods have no K8s API egress rule)
Confirm cross-tenant isolation¶
From tenant A's namespace, confirm a workload-labelled pod cannot reach tenant B's proxy:
kubectl run nettest-xtenant -n <tenant-a-namespace> --rm -it --restart=Never \
--image=curlimages/curl:latest \
--labels='actions-gateway/component=workload' \
--overrides='{"spec":{"automountServiceAccountToken":false}}' \
-- curl --noproxy '*' -sI --connect-timeout 5 \
https://actions-gateway-proxy.<tenant-b-namespace>.svc.cluster.local:8080
# Expected: connection timeout (tenant A's workload NP only allows egress to
# tenant A's own proxy ClusterIP, not arbitrary in-cluster services)
Confirm the GMC manager metrics endpoint is restricted to metrics: enabled namespaces¶
The GMC manager NetworkPolicy (networkPolicy.enabled=true, default) admits the manager's :8443 /metrics endpoint only from namespaces labelled metrics: enabled, while leaving the webhook port :9443 open so admission keeps working. Unlike the per-tenant policies above this one keys on the namespace label (a namespaceSelector), so the probe pod's own labels are irrelevant — only the label on its namespace decides. Verified at runtime on a Calico kind cluster on 2026-06-18 (Q83) and codified as the Calico-gated Tier-A spec Manager NetworkPolicy (E2E_GMC_ManagerMetricsNP_* / E2E_GMC_ManagerWebhookNP_AdmissionStillWorks).
URL="https://gmc-controller-manager-metrics-service.gmc-system.svc.cluster.local:8443/metrics"
# NEGATIVE: scrape from a namespace WITHOUT the label is blocked.
kubectl create namespace np-denied
kubectl run probe -n np-denied --rm -it --restart=Never \
--image=curlimages/curl:latest \
-- curl -sk -o /dev/null -w 'HTTP_CODE=%{http_code}\n' --connect-timeout 10 "$URL"
# Expected: curl: (28) ... Timeout; HTTP_CODE=000 (manager NP denies the unlabelled namespace)
# POSITIVE: scrape from a namespace WITH the label reaches the endpoint.
kubectl create namespace np-allowed
kubectl label namespace np-allowed metrics=enabled
kubectl run probe -n np-allowed --rm -it --restart=Never \
--image=curlimages/curl:latest \
-- curl -sk -o /dev/null -w 'HTTP_CODE=%{http_code}\n' --connect-timeout 10 "$URL"
# Expected: HTTP_CODE=401 (connection allowed through the NP; 401 is the metrics
# authn layer rejecting the missing bearer token — proof the TCP/TLS path reached
# the server, not the network blocking it)
Admission itself proves :9443 stays open: creating any ActionsGateway (valid or invalid) returns the validating webhook's verdict rather than a failed calling webhook … context deadline exceeded transport error.