Narrowing From Deny-All to Least Privilege
Goal
You build least-privilege authorization that starts from default deny and opens permissions only along the call graph, and you become able to explain the result of each call as a matrix.
Why it matters
The most common mistake in authorization design is approaching it in the direction of blocking what you need to block one by one. Then a path you left out stays open forever, and nobody knows. Conversely, if you start from a full deny, a path you left out is revealed immediately as a 403, so the design becomes complete. Istio expresses this reversal in a single line, spec: {} — an ALLOW policy exists but has no matching rule, so nobody gets through.
You must also internalize the evaluation order. It is CUSTOM → DENY → ALLOW, and ALLOW means "if there is even one policy, you must match to get through." That is, the moment you first add an ALLOW policy, that workload switches to allowlist mode. Accidents where someone adds a single policy without knowing this and all existing traffic is blocked are common. The property that DENY is evaluated before ALLOW conversely becomes a safety net — for things like management paths that "must be blocked no matter what," you lay an extra layer of DENY.
Finally, the principals of an authorization policy come from the identity in the mTLS certificate. A request that arrives in plaintext has an empty principal and never matches, so moving to STRICT is a precondition for authorization.
Steps
Preparation before starting. Lab Pods come up fresh for each lab, so the mesh configuration from the earlier lab does not remain. If kubectl get crd authorizationpolicies.security.istio.io is empty, run istioctl manifest generate --set profile=minimal > /root/istio/manifest.yaml and then kubectl apply -f /root/istio/manifest.yaml twice, and prepare the namespace with kubectl create ns mesh-lab && kubectl label ns mesh-lab istio-injection=enabled. You must also create anew, within this lab, the payments and ratings workloads that the selectors in steps 2 and 4 point to.
- Create an AuthorizationPolicy
deny-allinmesh-lab— put onlyspec: {}and apply it with neither a selector nor rules (leave out action so the default ALLOW applies). And in/root/istio/authz/out/denyall-note.txt, write why nobody gets through when there are no rules. - Create a ServiceAccount
frontendinmesh-lab(and also create the payments workload from the earlier lab if it does not exist), and create an AuthorizationPolicyallow-frontend:selector.matchLabels.appispayments,actionisALLOW, and inrules[0].from[0].source.principalswritecluster.local/ns/mesh-lab/sa/frontend. You must not attach thespiffe://prefix. - Add
rules[0].to[0].operationto the sameallow-frontendpolicy.methodsis only["GET"], andpathsincludes/api/*. And in/root/istio/authz/out/path-note.txt, write that path matching supports only prefix/suffix wildcards, not regular expressions. - Create the ratings workload (Service
ratings, Deploymentratings-v1, Pod labelapp: ratings) inmesh-lab, and create an AuthorizationPolicyallow-mesh-lab:selector.matchLabels.appisratings, andrules[0].from[0].source.namespacesis["mesh-lab"]. And in/root/istio/authz/out/ns-note.txt, write that namespace level is coarser control than service account (principal) level. - Create an AuthorizationPolicy
deny-admininmesh-lab:actionisDENY, andrules[0].to[0].operation.pathsincludes/admin/*. And in/root/istio/authz/order.txt, write the evaluation order one per line, in three lines — the first line must containCUSTOM, the secondDENY, and the thirdALLOW(you must not crowd them onto one line). - Create an AuthorizationPolicy
allow-v2-clientsinmesh-lab. Put two conditions inrules[0].when: one iskey: request.headers[x-api-version]withvalues: ["v2"], and the other iskey: source.namespacewithnotValues: ["legacy"]. The key of a condition that usesnotValuesmust not containrequest.headers. - Create an AuthorizationPolicy
audit-sensitiveinmesh-lab:actionisAUDIT, and inrules[0].to[0].operation.pathsspecify the path to audit (for example/internal/*). And in/root/istio/authz/out/audit-note.txt, write together that AUDIT only records and does not block traffic, and that it is for seeing the impact in advance before actually turning the policy on. - Create
/root/istio/authz/out/authz-matrix.json. Put 6 or more entries in thecasesarray, where each entry must haveexpected(either"allow"or"deny") andpolicy(the name of the policy that produced that result — it must actually exist inmesh-laband be a single word with no spaces). You need at least 2 expected allows and at least 2 expected denies.mesh-labmust have at least 5 AuthorizationPolicies, and save the result ofistioctl analyze -n mesh-labto/root/istio/authz/out/analyze.txt, with noError [.
Notes
- Lab Pods come up fresh for each lab, so the cluster state from the earlier lab does not remain. That is why keeping mesh configuration as manifests is itself reproducibility. It is the same property that, when you manage authorization policies in Git, the change history and approval process become an audit trail as they are.
- One example line of the matrix:
{"from": "frontend", "to": "payments", "method": "GET", "path": "/api/v1/charges", "expected": "allow", "policy": "allow-frontend"}. For denial examples, you can put a/admin/*request (deny-admin) and a caller not in the policy (deny-all). - For the
whencondition keys in step 6, you can userequest.headers[...],source.namespace,source.principal,request.auth.claims[...], and so on. - If a policy name does not exist in the cluster, step 8 fails. Check the names with
kubectl get authorizationpolicy -n mesh-laband then write the matrix. - Common mistake 1: attaching
spiffe://toprincipals. It is attached in concept explanations, but this field drops the prefix. - Common mistake 2: writing the evaluation order on one line joined by arrows. The order is judged by line number, so you must split it into three lines.
Lay down a full deny with empty rules
Create an AuthorizationPolicy deny-all in mesh-lab — put only spec: {} and apply it with neither a selector nor rules (leave out action so the default ALLOW applies). And in /root/istio/authz/out/denyall-note.txt, write why nobody gets through when there are no rules.
An allow policy with no rules at all is a full deny. The answer to why is in the last line of the evaluation order. It must apply to the whole namespace, so do not put a selector.
Open just one path by caller identity
Create a ServiceAccount frontend in mesh-lab (and also create the payments workload from the earlier lab if it does not exist), and create an AuthorizationPolicy allow-frontend: selector.matchLabels.app is payments, action is ALLOW, and in rules[0].from[0].source.principals write cluster.local/ns/mesh-lab/sa/frontend. You must not attach the spiffe:// prefix.
A policy attaches to the receiving workload. The identity is a service account, not an IP, and note that this field takes no prefix.
Narrow down to method and path
Add rules[0].to[0].operation to the same allow-frontend policy. methods is only ["GET"], and paths includes /api/*. And in /root/istio/authz/out/path-note.txt, write that path matching supports only prefix/suffix wildcards, not regular expressions.
This is the step that allows only reading. Confirm that path matching is not a regular expression and write it in the note.
Allow by namespace
Create the ratings workload (Service ratings, Deployment ratings-v1, Pod label app: ratings) in mesh-lab, and create an AuthorizationPolicy allow-mesh-lab: selector.matchLabels.app is ratings, and rules[0].from[0].source.namespaces is ["mesh-lab"]. And in /root/istio/authz/out/ns-note.txt, write that namespace level is coarser control than service account (principal) level.
It is coarser control than the identity level. Leave in the note when this is enough and when it is not.
Lay a deny safety net and write the evaluation order
Create an AuthorizationPolicy deny-admin in mesh-lab: action is DENY, and rules[0].to[0].operation.paths includes /admin/*. And in /root/istio/authz/order.txt, write the evaluation order one per line, in three lines — the first line must contain CUSTOM, the second DENY, and the third ALLOW (you must not crowd them onto one line).
A deny is evaluated before an allow, so it defends against mistakes in allow policies. The order summary must be written one per line.
Narrow down more finely with conditions
Create an AuthorizationPolicy allow-v2-clients in mesh-lab. Put two conditions in rules[0].when: one is key: request.headers[x-api-version] with values: ["v2"], and the other is key: source.namespace with notValues: ["legacy"]. The key of a condition that uses notValues must not contain request.headers.
You can attach several conditions, and you can also express exclusion conditions. Split the header condition and the exclusion condition into separate items.
Create a policy that only records without blocking
Create an AuthorizationPolicy audit-sensitive in mesh-lab: action is AUDIT, and in rules[0].to[0].operation.paths specify the path to audit (for example /internal/*). And in /root/istio/authz/out/audit-note.txt, write together that AUDIT only records and does not block traffic, and that it is for seeing the impact in advance before actually turning the policy on.
It is a device for seeing the impact first before actually turning the policy on. Write exactly what effect it has on traffic.
Build a per-call allow/deny matrix
Create /root/istio/authz/out/authz-matrix.json. Put 6 or more entries in the cases array, where each entry must have expected (either "allow" or "deny") and policy (the name of the policy that produced that result — it must actually exist in mesh-lab and be a single word with no spaces). You need at least 2 expected allows and at least 2 expected denies. mesh-lab must have at least 5 AuthorizationPolicies, and save the result of istioctl analyze -n mesh-lab to /root/istio/authz/out/analyze.txt, with no Error [.
You must write down, for each call, which policy makes it decided that way. The policy name must be one that actually exists in the cluster.