Drill — Lock Down a Mesh Namespace
One namespace, one real mistake made on purpose: you'll enrol a tiny payments service into the mesh, confirm two different callers can reach it under PeerAuthentication mode PERMISSIVE, then flip the namespace to STRICT and watch one of those callers break — not because anything is wrong with your YAML, but because a legitimate external health-check was never going to run a sidecar. You diagnose the break instead of guessing at it, fix it with a scoped port-level override instead of backing the whole namespace off PERMISSIVE again, and then close the door that's still standing open: mTLS alone never restricted who could call payments, only whether the call was encrypted. The last third of the drill writes and proves an AuthorizationPolicy that finally answers that question. This is the hands-on counterpart to Istio and Service Mesh Architecture — if PeerAuthentication or AuthorizationPolicy don't already feel familiar, read those first, then come back and actually type it.
Imagine a school hallway where every classroom door used to be unlocked — anyone could wander in. Today you're installing a badge reader on the payments-office door. First you turn it to "friendly mode," where the reader checks badges if you have one but still lets you in if you don't — nothing changes yet. Then you switch it to "strict mode," badge required, no exceptions — and the school nurse, who does a quick check-in from the hallway without ever getting a badge, suddenly can't get in either. She's not a burglar, she just never needed a badge before. You don't rip the reader back out; you tell it "the nurse's little side window is fine without a badge, but the main door still needs one." And then you notice something else: everyone with a badge can still walk into payments, not just the one person who's supposed to. So you add one more rule — this door opens for exactly one badge, nobody else's.
You need kind, kubectl, Docker or Podman, and istioctl (any recent release) on your machine. Everything runs on one throwaway kind cluster — no real cloud account, no billing, nothing to leave running afterward except your laptop's fans. Budget 25-40 minutes. Field/flag names move a little between Istio releases; if a command's output looks slightly different from what's shown here, that's a new release having shipped, not you doing it wrong — read the actual message and keep going.
How this drill works
☺ Like you're 10: One namespace, flipped from friendly to strict, one caller that predictably breaks, one narrow fix, and then a second, completely separate gap you close on top of it.
This drill has two acts, and it's worth knowing that going in. Act one is an mTLS story: PeerAuthentication decides whether a workload will accept plaintext, encrypted, or only encrypted connections — and flipping a whole namespace to STRICT in one step is exactly the move that catches an unmeshed caller nobody remembered. Act two is a completely different question that mTLS never answers on its own: once every connection to payments is encrypted and authenticated, who is actually allowed to call it? The answer, until you write one, is "anyone in the mesh" — which is rarely what you meant. You'll prove that gap is real before you close it, the same habit this course keeps insisting on: don't declare a fix done until you've watched it actually stop the thing it's supposed to stop.
Set up the mesh and a namespace worth locking down
☺ Like you're 10: One small service with two doors — a main door for real traffic, and a little side window just for health checks — plus three different visitors to test it with.
Spin up a cluster and install Istio's demo profile — it includes an ingress gateway you won't need today, but it's the fastest reliable way to get a working control plane for a drill:
kind create cluster --name mesh-drill
istioctl x precheck
istioctl install --set profile=demo -y
kubectl create namespace prod
kubectl label namespace prod istio-injection=enabled
kubectl create namespace ops # deliberately NOT labelled — stays outside the meshpayments gets two ports on purpose: 8080 is the real API, 8081 is a lightweight health endpoint that an external monitor polls. Keeping them on the same workload is what makes the port-level fix later actually mean something. A tiny nginx config serves both:
# payments-nginx-conf.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: payments-nginx
namespace: prod
data:
nginx.conf: |
events {}
http {
server {
listen 8080;
location / {
default_type application/json;
return 200 '{"service":"payments","status":"ok"}\n';
}
}
server {
listen 8081;
location /healthz {
return 200 'ok\n';
}
}
}# payments.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: payments
namespace: prod
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: checkout
namespace: prod
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: payments
namespace: prod
spec:
replicas: 1
selector:
matchLabels: { app: payments }
template:
metadata:
labels: { app: payments }
spec:
serviceAccountName: payments
containers:
- name: nginx
image: nginx:1.27-alpine
ports: [{ containerPort: 8080 }, { containerPort: 8081 }]
volumeMounts:
- { name: conf, mountPath: /etc/nginx/nginx.conf, subPath: nginx.conf }
volumes:
- name: conf
configMap: { name: payments-nginx }
---
apiVersion: v1
kind: Service
metadata:
name: payments
namespace: prod
spec:
selector: { app: payments }
ports:
- { name: http, port: 8080, targetPort: 8080 }
- { name: health, port: 8081, targetPort: 8081 }Three callers, standing in for three real-world roles. checkout — in-mesh, its own ServiceAccount, the caller that's actually supposed to hit the API. ops-healthcheck — deliberately outside the mesh, standing in for an external monitor that polls /healthz and structurally can't run a sidecar. rogue — in-mesh, default ServiceAccount, standing in for "anything else that happens to be running in the cluster" — the caller nobody explicitly invited.
# callers.yaml
apiVersion: v1
kind: Pod
metadata:
name: checkout-caller
namespace: prod
labels: { app: checkout-caller }
spec:
serviceAccountName: checkout
containers:
- { name: curl, image: curlimages/curl:8.10.1, command: ["sleep", "infinity"] }
---
apiVersion: v1
kind: Pod
metadata:
name: rogue
namespace: prod
labels: { app: rogue }
spec:
containers: # default ServiceAccount — nobody invited this one
- { name: curl, image: curlimages/curl:8.10.1, command: ["sleep", "infinity"] }
---
apiVersion: v1
kind: Pod
metadata:
name: ops-healthcheck
namespace: ops # unmeshed namespace — no sidecar, ever
labels: { app: ops-healthcheck }
spec:
containers:
- { name: curl, image: curlimages/curl:8.10.1, command: ["sleep", "infinity"] }kubectl apply -f payments-nginx-conf.yaml
kubectl apply -f payments.yaml
kubectl apply -f callers.yaml
kubectl -n prod rollout status deploy/payments
kubectl -n prod get pod payments-* -o jsonpath='{.spec.containers[*].name}'
# expect: nginx istio-proxy — the namespace label injected a sidecar automaticallyStep 1 — confirm the baseline under PERMISSIVE
☺ Like you're 10: Badge reader installed, set to friendly mode — everyone still gets in, badge or no badge, so nothing looks any different yet.
Make the intent explicit rather than relying on Istio's own default. An explicit PERMISSIVE policy accepts both plaintext and mTLS on every port — which means, right now, all three callers should succeed identically, and you won't yet be able to tell from the outside which connections are actually encrypted:
# peer-auth.yaml
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
name: payments-mtls
namespace: prod
spec:
selector:
matchLabels: { app: payments }
mtls:
mode: PERMISSIVEkubectl apply -f peer-auth.yaml
kubectl -n prod exec checkout-caller -- curl -s -o /dev/null -w 'checkout -> %{http_code}\n' http://payments.prod.svc.cluster.local:8080/
kubectl -n prod exec rogue -- curl -s -o /dev/null -w 'rogue -> %{http_code}\n' http://payments.prod.svc.cluster.local:8080/
kubectl -n ops exec ops-healthcheck -- curl -s -o /dev/null -w 'ops -> %{http_code}\n' http://payments.prod.svc.cluster.local:8081/healthzAll three should print 200. Hold that result in your head — it's the "before" picture for both acts of this drill: right now, anyone can reach payments, meshed or not, and encryption is optional rather than required.
Step 2 — flip to STRICT and watch what breaks
☺ Like you're 10: Flip the reader to strict mode. Everyone with a badge still walks straight in — the nurse without one is suddenly stuck outside, and she didn't do anything wrong.
Change one field and reapply:
mtls:
mode: STRICT # was PERMISSIVEkubectl apply -f peer-auth.yaml
kubectl -n prod exec checkout-caller -- curl -s -o /dev/null -w 'checkout -> %{http_code}\n' http://payments.prod.svc.cluster.local:8080/
kubectl -n ops exec ops-healthcheck -- curl -m 5 -s -o /dev/null -w 'ops -> %{http_code}\n' http://payments.prod.svc.cluster.local:8081/healthz; echo "exit: $?"checkout -> 200
exit: 56checkout never even noticed — its sidecar was already speaking mTLS to payments's sidecar the whole time; STRICT just makes that mandatory instead of optional. ops-healthcheck gets curl exit code 56, "recv failure: connection reset by peer": it never had a sidecar to upgrade the connection, so it's sending plain HTTP straight at a listener that now refuses anything that isn't mTLS. Nothing is broken in the sense of a bug — this is STRICT doing exactly its job. The question is what to do about a caller that was never going to be able to comply.
Not an exotic misconfiguration — a namespace-wide flip to STRICT on day one, with a scraper, a health-check, a legacy VM, or anything reached by pod IP left outside the mesh's knowledge entirely. Istio covers the fuller list of what usually breaks first; today you're reproducing and fixing exactly one instance of it, deliberately, so the shape of the failure sticks.
Step 3 — diagnose the break, don't guess at it
☺ Like you're 10: Before you touch anything, ask three questions in order: is the badge-reader's rulebook written correctly, did the rulebook actually reach the reader, and what did the reader do with the visitor who showed up?
Work the same three-layer ladder Istio and the ICA blueprint both teach — configuration, then control plane, then data plane — instead of jumping straight to a fix:
# 1. CONFIGURATION — is the PeerAuthentication itself valid and unambiguous?
istioctl analyze -n prod
# clean — analyze checks your YAML's shape, not runtime callers it never sees
# 2. CONTROL PLANE — did the STRICT policy actually reach payments's proxy?
istioctl proxy-status | grep payments
# payments-xxxxx.prod SYNCED SYNCED SYNCED SYNCED istiod-... 1.2x.x
# 3. DATA PLANE — what does the proxy that rejected the call actually believe?
istioctl x describe pod -n prod $(kubectl -n prod get pod -l app=payments -o jsonpath='{.items[0].metadata.name}')Pod: payments-7c9f8d-abcde
Effective PeerAuthentication:
Workload mTLS mode: STRICT
--------------------
Effective AuthorizationPolicies:
(none, all traffic is allowed)That output is the whole diagnosis in two lines. Nothing is misconfigured — istioctl analyze is clean because the policy itself is well-formed, and proxy-status confirms it genuinely reached the workload. The proxy is doing precisely what STRICT says: refuse anything that isn't mTLS. The problem was never the policy; it's that one real caller structurally can't meet it. Notice, too, the second block — (none, all traffic is allowed) under authorization. File that away; it's exactly the gap Step 6 exists to close.
Step 4 — fix it with a port-level override, not a namespace-wide retreat
☺ Like you're 10: Don't unlock the whole building again just because one visitor doesn't carry a badge — leave a small side window open for her specifically, and keep the main door locked.
The tempting fix is to drop the whole namespace back to PERMISSIVE — and that would work, in the sense that it makes the error go away while quietly un-doing the entire point of this drill. The correct fix is narrower: PeerAuthentication supports a portLevelMtls map that overrides the workload-wide mode for one named port only. Health checks on 8081 get to stay plaintext-tolerant; the real API on 8080 stays exactly as strict as it was:
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
name: payments-mtls
namespace: prod
spec:
selector:
matchLabels: { app: payments }
mtls:
mode: STRICT # everything else on this workload — mTLS required
portLevelMtls:
8081:
mode: PERMISSIVE # the health port only — plaintext or mTLS, either is finekubectl apply -f peer-auth.yaml
kubectl -n ops exec ops-healthcheck -- curl -m 5 -s -o /dev/null -w 'ops (8081) -> %{http_code}\n' http://payments.prod.svc.cluster.local:8081/healthz
kubectl -n ops exec ops-healthcheck -- curl -m 5 -s -o /dev/null -w 'ops on 8080! -> %{http_code}\n' http://payments.prod.svc.cluster.local:8080/ ; echo "exit: $?"The health check comes back 200. The second command — the unmeshed pod trying its luck against the real API port — still fails with the same connection reset as before. That second line is the proof this fix is actually scoped, not a disguised namespace-wide rollback: only the port you explicitly named changed behavior.
portLevelMtls exists precisely because "everything on this workload" and "one specific integration point on this workload" are usually different security decisions, and a single mtls.mode field can't express both at once. The instinct to widen a policy until an error disappears is almost always the wrong instinct — the right question is always "what's the smallest true statement I can make about what this port actually needs?"
"Nobody on my team wrote a line of TLS code and nobody's going to. What we did have to do, once, was tell the platform team our uptime monitor hits /healthz and nothing else — and once they scoped the override to that one port, we stopped thinking about it completely. That's the whole trade: a five-minute conversation instead of owning a certificate rotation schedule forever."
Step 5 — prove the mesh alone isn't an authorization boundary
☺ Like you're 10: Everyone with a badge can still open this door — the reader never actually checked whose badge it was, only that it had one.
Before writing anything new, confirm the gap istioctl x describe pod already told you about in Step 3 — (none, all traffic is allowed) — is real and not just a line of output you skimmed past:
kubectl -n prod exec rogue -- curl -s -o /dev/null -w 'rogue -> %{http_code}\n' http://payments.prod.svc.cluster.local:8080/rogue -> 200rogue was never given permission to call payments. Nobody wrote a ServiceEntry, a NetworkPolicy, or an AuthorizationPolicy naming it. It succeeds anyway, because STRICT mTLS only ever asked one question — is this connection encrypted and does the caller present a valid mesh certificate? — and rogue, being just another in-mesh workload with its own sidecar and its own SPIFFE identity, answers that question perfectly. Encryption and authentication are not authorization. That's not a bug in what you built in Step 4 — it's the whole reason AuthorizationPolicy exists as a separate resource in the first place.
Step 6 — write and prove a targeted AuthorizationPolicy
☺ Like you're 10: Now write the actual guest list — this exact badge and no other gets through the main door — and don't forget the side window still needs its own separate rule, or you'll accidentally lock the nurse back out.
Write one ALLOW policy with two rules: the real API, restricted to checkout's identity; the health port, left open to anyone, matching what Step 4 already decided about it. Match on principals — the workload's SPIFFE identity, derived from its ServiceAccount — never an IP address, which the mesh doesn't consider trustworthy for this purpose:
# authz.yaml
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: payments-allow-checkout
namespace: prod
spec:
selector:
matchLabels: { app: payments }
action: ALLOW
rules:
- from:
- source:
principals: ["cluster.local/ns/prod/sa/checkout"]
to:
- operation:
ports: ["8080"]
- to: # no `from` here = open to any caller
- operation:
ports: ["8081"] # keep the health port reachable — see belowkubectl apply -f authz.yaml
kubectl -n prod exec checkout-caller -- curl -s -o /dev/null -w 'checkout on 8080 -> %{http_code}\n' http://payments.prod.svc.cluster.local:8080/
kubectl -n prod exec rogue -- curl -s -o /dev/null -w 'rogue on 8080 -> %{http_code}\n' http://payments.prod.svc.cluster.local:8080/
kubectl -n ops exec ops-healthcheck -- curl -m 5 -s -o /dev/null -w 'ops on 8081 -> %{http_code}\n' http://payments.prod.svc.cluster.local:8081/healthzcheckout on 8080 -> 200
rogue on 8080 -> 403
ops on 8081 -> 200rogue now gets 403 — a real RBAC: access denied refusal from the sidecar, not a network-level timeout. That's the entire drill's second act, proven in one line of curl output.
The instant an ALLOW policy selects a workload, that workload becomes default-deny for everything not explicitly matched — including ports no rule mentions. Ship only the first rule above, forget the second, and you will silently re-break the health check you just fixed in Step 4 — except this time it fails with 403 instead of a connection reset, a different enough symptom that it's easy to mistake for a new problem instead of the same class of mistake wearing a different error code. Read every AuthorizationPolicy you write and ask: have I accounted for every port this workload actually serves?
Delete the second rule from authz.yaml, reapply, and re-run the ops health-check curl. Watch it fail with 403 — a live demonstration of the warning above, from a mistake you made on purpose instead of one that pages someone at 2am. Restore the second rule before continuing.
Clean up
☺ Like you're 10: Take the whole practice hallway down — nothing here needs to keep running once you've seen it work.
kind delete cluster --name mesh-drillComfortable with all six steps? Push past the minimum. (1) Add a RequestAuthentication requiring a JWT from a fake issuer, then extend the ALLOW rule so checkout needs both its SPIFFE identity and a valid token — inside one - source: block, not two, or you'll flip an AND into an OR without meaning to. (2) Reproduce the drill using ambient mode instead of sidecar injection, and notice which steps change and which don't — PeerAuthentication and AuthorizationPolicy mean exactly the same thing either way, only the thing enforcing them changes. (3) Delete the second AuthorizationPolicy rule for real this time, but instead of noticing the break by hand, write a one-line istioctl analyze or curl-based check you could wire into CI so this exact regression can never ship quietly again.
Professor Owl: Before anyone touches a fix — what does istioctl analyze say, and did the policy actually reach the proxy? Diagnose the shape of the break before you reach for a tool.
Foxy: It says the config is fine. So why is the health check still failing?
Professor Owl: Because the config was never wrong, Foxy. STRICT is doing its job perfectly — the health-check just structurally can't meet it. That's not a bug to fix, it's a caller to scope around.
Gizmo the Gremlin: Or — simplest fix in the world — just set the whole namespace back to PERMISSIVE. Error gone. Ship it. 🤑
Timmy the Turtle: That's not a fix, Gizmo, that's deleting the lock because one key doesn't turn it. portLevelMtls exists for exactly this — one port stays open, the rest stays exactly as strict as it was.
Benny the Beaver: Okay, but even after that fix, my rogue pod could still curl payments and get a clean 200. Nobody asked it who it was.
Timmy the Turtle: Right — mTLS answers "is this encrypted," never "is this allowed." That's AuthorizationPolicy's question, and it's why this drill has a second act at all.
Professor Owl: And once you write that policy, check every port again, not just the one you were focused on. A workload doesn't get to stay open anywhere by accident once you've decided to lock down anywhere at all.
1. What's the practical difference between PeerAuthentication mode PERMISSIVE and STRICT, and which side of the connection — caller or callee — actually enforces it? 2. Why did ops-healthcheck start failing the moment prod flipped to STRICT, when checkout kept working the entire time without any changes on its side? 3. What does portLevelMtls let you express that a single mtls.mode field on the same PeerAuthentication can't — and why was it the right fix here instead of reverting the whole namespace to PERMISSIVE? 4. Before Step 6, could rogue successfully call payments on port 8080? Why, given that STRICT mTLS was already fully enforced at that point? 5. The moment the AuthorizationPolicy in Step 6 was applied, what happened to traffic on port 8081, and why did the policy need a second rule to prevent it? 6. What's the "prove it" habit this drill kept repeating, and why isn't watching kubectl apply succeed enough on its own to call a fix done?
Check your answers
PERMISSIVEaccepts both plaintext and mTLS connections;STRICTaccepts only mTLS. Enforcement happens on the receiving workload's sidecar (or ztunnel, in ambient mode) — it's the callee's proxy that decides whether to accept an inbound connection, which is why the caller's own meshed status is what determines success or failure.ops-healthcheckruns in theopsnamespace, which was deliberately never labelled for injection, so it has no sidecar and sends plain HTTP.checkoutalready had a sidecar speaking mTLS topaymentsunderPERMISSIVE;STRICTonly removed the plaintext option it was never using, so nothing changed for it.- A single
mtls.modefield can only state one mode for the entire workload.portLevelMtlslets one port (8081, the health endpoint) stayPERMISSIVEwhile every other port on the same workload (8080, the real API) staysSTRICT. It's the right fix because it's the narrowest true statement about what each port actually needs — reverting the whole namespace toPERMISSIVEwould have also re-permitted plaintext on the real API, undoing the entire point of locking the namespace down. - Yes —
rogueis an in-mesh pod with its own sidecar and its own valid SPIFFE identity, so it satisfiesSTRICTmTLS exactly as well ascheckoutdoes.PeerAuthenticationnever asks which identity is calling, only whether the connection is encrypted and the certificate is valid — answering "who is allowed" is a completely separate resource,AuthorizationPolicy. - Port 8081 traffic became default-deny, because the moment any
ALLOWpolicy selects a workload, everything not explicitly matched by a rule is refused — including ports no rule mentions at all. The policy needed a second rule with nofrom(open to any caller) scoped to port 8081, or the health check that Step 4 had just fixed would have broken again, this time with a403instead of a connection reset. - Deliberately trying to reproduce the exact failure a fix claims to prevent — calling
paymentsasroguebefore writing theAuthorizationPolicyto confirm the gap was real, and calling it again after to confirm the policy actually stops it. A resource accepted cleanly bykubectl applyhas only been validated as well-formed YAML — it has never been tested against the thing it's supposed to prevent, and the first time it's asked to do that job for real shouldn't be the first time anyone finds out whether it works.
Both acts hold — an unmeshed caller can reach exactly the one port it needs, and exactly one identity can reach the real API? That's the whole drill. For the concepts behind every resource used here at full depth, see Istio and Service Mesh Architecture; for how AuthorizationPolicy sits beside the admission-time policy Kyverno enforces instead, see Policy-as-Code Philosophy. The full exam blueprint this drill's scope comes from is the ICA — the exam. Practice the mesh-and-policy skills again, at capstone scale, in Capstone Part 3 — Mesh & Policy — or step back to Build Your Cert Tracker — Start Here for the full five-part version. Want a different single skill next? Try Drill — Write an Enforcing Kyverno Policy or Drill — Diagnose a Stuck Argo CD Sync.
Setup
prod for injection, leave ops unlabelledkubectl -n prod get pod payments-* shows two containers — nginx and istio-proxy.payments, all three callers, and an explicit PERMISSIVE PeerAuthenticationcheckout, rogue, and ops-healthcheck all curl payments and get 200.Break it, diagnose it
payments-mtls to STRICT, confirm ops-healthcheck fails while checkout keeps workingops-healthcheck's curl exits 56 (connection reset) and checkout's still returns 200.istioctl analyze, proxy-status, and x describe pod — don't just guess at the fixx describe pod showing Workload mTLS mode: STRICT and no matching AuthorizationPolicy yet.Fix the mTLS break, then close the authorization gap
portLevelMtls override for port 8081, confirm the health check resumes while 8080 stays STRICTops-healthcheck gets 200 on :8081 but still fails on :8080.rogue can currently call payments on 8080 with no identity check at all — the gap you're about to closerogue's curl to :8080 returns 200, before any AuthorizationPolicy exists.AuthorizationPolicy — checkout only on 8080, health left open on 8081payments-allow-checkout is applied with both rules present.rogue denied, checkout allowed, ops-healthcheck still open on 8081rogue gets 403, checkout gets 200 on :8080, ops-healthcheck gets 200 on :8081, all three confirmed in the same sitting.Clean up
kind get clusters no longer lists mesh-drill.