External request source header (ingress) troubleshooting
Summary
HEAT services distinguish external requests (from outside the cluster, via ingress) from in-cluster requests using the request header X-HEAT-Request-Source: external. Ingress is responsible for injecting it. When injection fails, browser traffic is classified as in-cluster and token validation is skipped.
This page records a real failure on an nginx-class cluster, the diagnosis, and the fix. It exists because the failure is silent: no error is logged by ingress, and only some endpoints break.
Key finding: nginx.ingress.kubernetes.io/proxy-set-headers is not a valid per-Ingress annotation. In ingress-nginx, proxy-set-headers is a global ConfigMap option, not an annotation. An Ingress carrying it is ignored with no warning.
Who reads the header
| Consumer | Behaviour when header absent |
|---|---|
api/v2 AuthenticationMiddleware | Skips token validation, does not set AuthenticatedUser |
api/v2 RequiresRoleAttribute | Short circuits, skips the role check |
core/api CoreAuthMiddleware (system.core.auth_mode: ClusterOnly) | Treats request as in-cluster |
Header name and comparison live in HeatRequestSourceConstants (present in v2 API, Core API, and HEAT Auth).
Symptom
Only some v2 dashboard endpoints return 401. Others work normally.
GET /api/v2/dashboard/dimensionsreturns 401 (session list page fails).GET /api/v2/dashboard/{dimensionId},/layout-config,/datareturn 200 (session detail page renders).
The split is not about roles or tokens. It is about whether the action reads AuthenticatedUser:
- Controllers that read it (
DashboardCatalogController,DashboardOverviewController,DashboardReportsController,DashboardStaticsController) return a bodylessUnauthorized()when it is null. - Controllers that do not read it (
DashboardController) run normally, becauseRequiresRoleAttributealready short circuited.
Diagnose from the 401 response body
The body identifies the code path uniquely. Use this before changing any configuration.
| Response body | Meaning |
|---|---|
{"type":"...rfc9110#section-15.5.2","title":"Unauthorized","status":401,...} | Header missing. Middleware skipped, controller returned bodyless Unauthorized(), [ApiController] converted it to ProblemDetails. |
{"status":"error","message":"Missing or invalid Authorization header"} | Header present, middleware running, no bearer token supplied. |
{"status":"error","message":"Unauthorized. Invalid or expired token."} | Header present, middleware running, HEAT Auth rejected the token. |
{"error":"Authentication required"} | Header present, RequiresRoleAttribute found no authenticated user. |
The RFC9110 ProblemDetails shape is the tell for a missing header. A token problem never produces it.
The header is injected between ingress and the pod. The browser never sends it and never receives it, so browser devtools cannot confirm or deny its presence.
Strict-Transport-Securityin the response is unrelated (it is a stock ingress-nginx default).
Diagnosis steps
Run in order. All are read only.
1. Confirm the header is the discriminator
port-forward bypasses ingress, reproducing the no-header case with no extra images (works air gapped):
kubectl port-forward -n heat svc/heat-v2-api 5000:5000Then compare, without and with the header:
curl -i "http://localhost:5000/api/v2/dashboard/dimensions"curl -i -H "X-HEAT-Request-Source: external" "http://localhost:5000/api/v2/dashboard/dimensions"If the body flips from ProblemDetails to Missing or invalid Authorization header, the header is the cause.
2. Check the ingress that serves the host
kubectl get ingress -n heat -o custom-columns='NAME:.metadata.name,HOSTS:.spec.rules[*].host,CLASS:.spec.ingressClassName'Confirm which Ingress matches the host, and that it routes /api/v2 to heat-v2-api.
3. Check whether the header reaches the generated nginx config
This is the authoritative check.
kubectl exec -n app-routing-system deploy/nginx -- grep -c "X-HEAT-Request-Source" /etc/nginx/nginx.confValidate the instrument first. A zero result is only meaningful if the file is the live config:
kubectl exec -n app-routing-system deploy/nginx -- grep -c "proxy_set_header" /etc/nginx/nginx.confExpect thousands. If that is also zero, the path or container is wrong and the negative result means nothing.
4. Rule out the plausible-but-wrong causes
Each of these was checked and found not to be the cause. Checking them is still worthwhile, because they produce the same symptom.
| Candidate | Check | Note |
|---|---|---|
| Wrong host rule matching | kubectl get ingress (step 2) | Host matched correctly. |
| Missing target ConfigMap | kubectl get configmap heat-external-request-headers -n heat | Present and correct. |
| Annotation validation blocking | kubectl get cm nginx -n app-routing-system -o yaml | annotations-risk-level: Critical, allow-cross-namespace-resources: "true", so nothing blocked. |
| Stale controller sync | kubectl get pods -n app-routing-system | Pods predated the ConfigMap by 8 days, but a restart was not the fix. |
| Controller RBAC | kubectl auth can-i get configmaps -n heat --as="system:serviceaccount:app-routing-system:nginx" | Returned no, but this was not the operative cause. See below. |
| Outbound auth URL wrong | OPENID_URL env on heat-v2-api | http://heat-auth (in cluster). Also ruled out by the 401 body. |
The RBAC result was a red herring. The controller cannot read ConfigMaps in heat, which is true but irrelevant: it never attempts the read, because it never processes the annotation. The decisive clue was zero RBAC or reflector errors in the controller logs across 8 days. A controller genuinely failing to read a referenced ConfigMap logs errors continuously.
Beware two artefacts that produce false results:
kubectl auth can-i --as="...:$sa"with an unset$saevaluates an empty identity and always returnsno. Echo the variable before trusting the verdict.- PowerShell mangles jsonpath containing
{"\n"}or|. Use one jsonpath expression per command.
Root cause
deploy/helm/templates/_helpers.tpl emits, for non-traefik classes:
nginx.ingress.kubernetes.io/proxy-set-headers: "<namespace>/heat-external-request-headers"This annotation does not exist in ingress-nginx:
- The annotations reference does not list
proxy-set-headers. The only similar entry,auth-proxy-set-headers, applies to external auth services only. - The ConfigMap reference lists
proxy-set-headersas a global option taking anamespace/namevalue, set on the controller’s own ConfigMap.
So the annotation is silently ignored, and the header is never injected. This affects any nginx-class HEAT deployment using this config shape, not one environment.
Fix applied
Replace the non-existent annotation with configuration-snippet, which is a real annotation, on every Ingress routing /api/v2.
Preconditions on the cluster (verify first):
kubectl get cm nginx -n app-routing-system -o yamlallow-snippet-annotations: "true"annotations-risk-levelpermits the annotation (Criticalhere)annotation-value-word-blocklistdoes not containproxy_set_header
Back up before changing:
kubectl get ingress heat-ingress-next -n heat -o yaml > backup-heat-ingress-next.yamlConfirm no snippet already exists, so nothing is replaced:
kubectl get ingress heat-ingress-next -n heat -o jsonpath='{.metadata.annotations.nginx\.ingress\.kubernetes\.io/configuration-snippet}'Apply, without --overwrite, so the command fails rather than clobbering if a snippet appeared:
kubectl annotate ingress heat-ingress-next -n heat 'nginx.ingress.kubernetes.io/configuration-snippet=proxy_set_header X-HEAT-Request-Source external;'Repeat for every Ingress that routes /api/v2. On the affected cluster that was both heat-ingress-next (host next.<env>) and heat-ingress-main (host <env>).
Notes:
- Single quotes are required in PowerShell. The value contains a space and a semicolon.
kubectl annotatepatches only the named key. Other annotations (cert-manager,proxy-body-size, timeouts, Helm metadata) are untouched.configuration-snippetis a single string of nginx directives. A secondannotateon the same key replaces the value. To add a directive to an existing snippet, read the current value and re-set it with the addition appended.
Verification
Header reaches the generated config (was 0, expect a high count):
kubectl exec -n app-routing-system deploy/nginx -- grep -c "X-HEAT-Request-Source" /etc/nginx/nginx.confEnd to end, through the public host, no token required:
curl -s "https://<host>/api/v2/dashboard/dimensions?from=2026-08-26&to=2026-09-02&orderBy=descending&pageNumber=1"The body must flip from ProblemDetails to:
{"status":"error","message":"Missing or invalid Authorization header"}That confirms AuthenticationMiddleware is running. Then load the session list in the browser with a real token.
On the affected cluster the header appeared immediately, with no controller restart. That retroactively confirms the diagnosis: the annotation was never read, so this was never a sync timing problem.
Rollback
The annotation did not exist beforehand, so removing it restores the prior state exactly (trailing dash deletes):
kubectl annotate ingress heat-ingress-next heat-ingress-main -n heat nginx.ingress.kubernetes.io/configuration-snippet-Known limitations and follow-ups
The annotation is manual and will be lost. These Ingresses are Helm managed (meta.helm.sh/release-name: heat). The next helm upgrade reverts them and the 401 returns. Treat the above as a stopgap.
The chart still needs fixing. _helpers.tpl branches only between traefik and the invalid nginx annotation, with no branch for the AKS app routing add on. Options, neither unconditionally correct:
| Option | Works | Risk |
|---|---|---|
configuration-snippet annotation | Wherever HEAT controls the Ingress | allow-snippet-annotations is often disabled for hardening, and is operator managed on AKS |
Global proxy-set-headers on the controller ConfigMap | Self managed ingress-nginx | Not settable on AKS app routing: that ConfigMap is reconciled by aks-app-routing-operator |
Choosing between them is an architecture decision. Raise with the HEAT engineering lead.
The failure mode is fail open, not fail closed. With the header absent, AuthenticationMiddleware skips token validation rather than rejecting the request. Endpoints that do not read AuthenticatedUser (the whole DashboardController surface) were therefore served to external traffic without token validation. Core API’s ClusterOnly mode reads the same header and needs the same review. Any change here should consider requiring proof of in-cluster origin rather than inferring trust from an absent header.
An unused RBAC grant may exist. If a Role and RoleBinding named heat-external-request-headers-reader was created in heat during diagnosis, it is inert once the snippet fix is used. It is only needed for the global ConfigMap option.
kubectl delete role,rolebinding heat-external-request-headers-reader -n heatRelated
- Platform configuration for
system.core.auth_mode deploy/helm/templates/ingress-external-traffic.yamlcreates the ConfigMap and traefik middlewaredeploy/k8s/base/ingress/static ingress manifests