TT Lab
Get started
Learn Learning paths Courses

Istio Deep Dive — Why It Flows That Way

Authorization policies are translated into a filter chain

Continue in TT Lab

In one line

An AuthorizationPolicy becomes the envoy.filters.http.rbac filter of the workload's inbound listener. DENY policies gather into one filter and ALLOW policies into another, and the DENY filter goes in front. The two filters are linked in a chain, and a request must pass both to reach the app. A dry-run is the same policy going into shadow_rules instead of rules.

Why this was needed

If you code authorization rules separately in each app, each language implements them differently, and when you audit, you do not know what is where. Istio moved this into the proxy. Rules are written in one place as Kubernetes resources, and enforcement is done the same way by the Envoy in front of every Pod.

But the rules people write mix "block this (DENY) and allow that (ALLOW)", while a single RBAC filter in Envoy has exactly one action. So translation needs a rule. If you do not know this rule, you cannot explain two accidents often seen in production. One is the accident where adding a single ALLOW policy makes all the remaining traffic a 403, and the other is the accident where a single DENY policy that wrote only a path even cuts database connections.

How it works

The evaluation order in the Istio documentation is five rows.

Order Condition Verdict
1 A CUSTOM policy denies Deny
2 Matches a DENY policy Deny
3 This workload has no ALLOW policy at all Allow
4 Matches an ALLOW policy Allow
5 Anything else Deny

istiod translates this table into a filter chain. Row 2 is an RBAC filter with action: DENY, and rows 4 and 5 are an RBAC filter with action: ALLOW. The ALLOW filter passes only when "there is a matching policy", so if nothing matches, it denies on the spot — that is why row 5 is not needed separately. Row 3 is implemented by not creating a filter. If an RBAC filter's rules has no policy at all, it denies every request, so for a workload with no ALLOW policy, the ALLOW filter itself is not put in.

In a filter chain, each filter can do only two things: "pass to the next" and "deny on the spot". There is no way to skip the next filter and allow straight away. So for a request to reach the app, it must pass both the DENY filter and the ALLOW filter. Logically it is an AND. That is why the verdict is the same even if you swap the order of the two filters. What changes when the order changes is the statistics. Both filters raise the same http.<stat_prefix>.rbac.allowed/denied, so one request can raise allowed in the first filter and denied in the second.

Most policy attributes are carried over as they are. paths: ["/admin*"] becomes url_path.path.prefix: /admin — so /administrator is caught too — and methods: ["GET"] becomes an exact match on the :method header. Each policy gets a name like ns[default]-policy[deny-admin]-rule[0], so you can trace back to the original resource from a denial log or config_dump.

A dry-run is putting the annotation istio.io/dry-run: "true" on a policy. istiod puts that policy into shadow_rules. The RBAC filter evaluates the shadow side and leaves only the shadow_allowed/shadow_denied statistics and dynamic metadata, without using it in the verdict. A filter with no rules denies nothing. If you give shadow_rules_stat_prefix, a prefix is inserted into the middle of the statistics name.

What it looks like in the field

I added one ALLOW and everything got blocked. It is because of row 3. A workload that was "allow everything" when there were 0 ALLOW policies becomes "allow only what matches" the moment there is 1. If the health check path or another service's call is not in the new ALLOW, it becomes a 403 immediately. If the body is RBAC: access denied, the proxy blocked it, and you tell it apart from the app's 403 by the body.

A DENY policy cut a TCP service. DENY treats an attribute the request does not have as a match. It is a design so that the blocking side leaves no gaps. So a DENY that wrote only a path matches and cuts every TCP connection, which has no concept of a path. istioctl validate warns about this and recommends narrowing it with ports. The exit code is 0, so it is easy to miss in CI.

I am afraid to turn on a new policy right away. Put it in with dry-run first, see whether shadow_denied rises only for the requests you expected, and then remove the annotation. To see this statistic on a production dashboard, you need to know the exact name, including the prefix.

Official documentation: Authorization Policy · Envoy RBAC filter

What you will do in the next lab

You write DENY and ALLOW policies, read the istioctl warning, and first predict the results of four requests by the evaluation order. You translate the two policies into two RBAC filters, start them in Envoy and compare with your prediction. You swap the filter order, remove the ALLOW filter, and move the DENY into shadow_rules, confirming the three rows of the evaluation order for yourself.