I was writing a CEL authorization policy for an LLM route in Solo Enterprise for agentgateway : restrict which models a caller may reach, and refuse callers whose identity provider says they are in a jurisdiction the company cannot serve. Two conditions, both read off the same request.
The first version I wrote allowed a request it should have denied. The second version denied every request, including the ones that should have passed. Neither one reported an error. Both showed Accepted and Attached in the policy status.
The working policy and the demo that proves it are in themsquared/agentic-demo
under manifests/governance/
. Everything below was verified against a live cluster running v2026.8.2.
The policy I meant to write
Two rules on a route called governed-llm. The caller’s JWT carries a country claim from the identity provider. The request body names a model.
- The country must be present and not on a restricted list.
- The model must be one the AI governance group has approved.
Written the obvious way, that is two entries in matchExpressions:
traffic:
authorization:
action: Allow
policy:
matchExpressions:
- "has(jwt.country) && !(jwt.country in ['CU', 'IR', 'KP', 'SY'])"
- "json(request.body).model in ['claude-sonnet-4-6', 'claude-haiku-4-5']"
That reads as “both must hold”. It is not what it does.
Failure one: matchExpressions is OR, and it fails open
I have two test users from the same Keycloak realm, in the same group, with the same permissions. The only difference is the country claim: maria is US, pat is IR.
With the two-entry policy above:
| Caller | Model | Expected | Actual |
|---|---|---|---|
| maria (US) | approved | 200 | 200 |
| pat (IR) | approved | 403 | 200 |
| maria (US) | not approved | 403 | 200 |
| no JWT | approved | 401 | 401 |
Both of the requests that should have been refused went through to the provider.
The reason is that entries in matchExpressions are OR’ed. A request is allowed when any expression evaluates true. pat fails the country rule but satisfies the model rule, so the policy allows the request. maria asking for an unapproved model is the mirror image: the country rule passes, so the model rule never gets to matter.
The documentation does state the behavior, in one sentence: requests that do not match any of the conditions are denied. Read closely that is unambiguous. Read at the speed you actually read reference docs, next to a YAML block with a list under it, and “a list of conditions” looks like a list of requirements.
The fix is to stop treating the list as a conjunction and write one expression:
matchExpressions:
- >-
has(jwt.country) && !(jwt.country in ['CU', 'IR', 'KP', 'SY'])
&& json(request.body).model in ['claude-sonnet-4-6', 'claude-haiku-4-5']
Same two conditions, one entry, joined with &&. Now the table comes out right:
| Caller | Model | Result |
|---|---|---|
| maria (US) | approved | 200 |
| pat (IR) | approved | 403 |
| maria (US) | not approved | 403 |
| no JWT | approved | 401 |
What makes this one worth writing down is the direction of the failure. A policy with one entry per rule is the natural way to write it, it looks correct in review, the resource reports healthy, and it permits traffic. Nothing surfaces until someone audits denials that never happened.
If your allowlist is per-credential rather than per-claim, there is a second place to put it: agentgateway v1.5.0 added an allowedModels list directly on the API key, which I covered in per-key LLM budgets that return 429
. The CEL route is the one to use when the decision depends on something in the token rather than on which key was presented.
Failure two: llm.requestModel is empty when authorization runs
Fixing the first bug, I reached for what looked like the correct variable. agentgateway exposes an llm context with the model, the provider, token counts, and realized cost. Reading the model from there is cleaner than parsing the body:
matchExpressions:
- "llm.requestModel in ['claude-sonnet-4-6', 'claude-haiku-4-5']"
Every request now returned 403. Not just the ones naming an unapproved model. All of them, including a request for a model literally present in that list.
llm.requestModel exists, and it is documented. It belongs to the backend AI phase, which runs after routing has selected a backend. A traffic.authorization policy runs earlier, at the route level. At that point the llm context has not been populated, the expression cannot evaluate true, and a policy whose action is Allow denies everything.
This one fails closed, which is the safer direction, but it is confusing in a specific way: the policy looks like it is working. Requests for unapproved models get 403, exactly as designed. You only catch it if your test set includes a request that is supposed to succeed.
Isolating which variables actually resolve
When a CEL expression silently never matches, the fastest way to find out why is to hold the request constant and vary only the expression. I patched one field on the live policy and re-ran the same two requests each time, one naming an approved model and one naming an unapproved model:
kubectl patch eagpol cel-probe -n agentgateway-system --type=json \
-p "[{\"op\":\"replace\",\"path\":\"/spec/traffic/authorization/policy/matchExpressions\",\"value\":[\"$EXPR\"]}]"
| Expression under test | approved model | unapproved model |
|---|---|---|
true | 200 | 200 |
'admins' in jwt.Groups | 200 | 200 |
jwt.preferred_username == 'demo' | 200 | 200 |
has(llm.requestModel) | 403 | 403 |
llm.requestModel in [...] | 403 | 403 |
json(request.body).model in [...] | 200 | 403 |
The first three lines prove the policy is attached and that JWT claims resolve fine at this phase. Line four is the diagnosis: has(llm.requestModel) is false, so the variable is not merely holding an unexpected value, it is absent. The last line is the working form.
has() is the probe worth remembering. It separates “this variable holds something I did not expect” from “this variable does not exist here”, and those have completely different fixes.
The policy that works
Both findings in one resource:
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayPolicy
metadata:
name: governed-llm-access
namespace: agentgateway-system
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: governed-llm
traffic:
authorization:
action: Allow
policy:
matchExpressions:
- >-
has(jwt.country) && !(jwt.country in ['CU', 'IR', 'KP', 'SY'])
&& json(request.body).model in ['claude-sonnet-4-6', 'claude-haiku-4-5', 'acme-standard', 'acme-premium']
acme-standard and acme-premium are virtual model names, mapped to real models by a separate modelAliases policy on the backend. Callers ask for a tier, the platform team decides what that tier means today, and the allowlist keeps naming the same two strings when the underlying model changes.
A denied request gets HTTP 403 with the body authorization failed, and the caller never reaches the provider. That is the part that matters for a jurisdiction rule: nothing was sent upstream, so there is nothing exported, nothing logged on the provider side, and nothing billed.
Why these two belong in the same post
They are the same class of bug seen from both sides. In each case the policy compiles, the controller reports it healthy, and the resource status says Accepted and Attached. The only signal is the HTTP status of a request you have to think to send.
That suggests a test set rather than a review habit. For any allow-style authorization policy, send four requests: one that should pass, one that fails each condition independently, and one with no credential at all. The OR bug is invisible unless you send a request that violates exactly one condition. The phase bug is invisible unless you send one that violates none.
# should pass
curl -s -o /dev/null -w '%{http_code}\n' localhost:8081/governed-llm/v1/chat/completions \
-H "Authorization: Bearer $MARIA" -H 'content-type: application/json' \
-d '{"model":"acme-standard","max_tokens":8,"messages":[{"role":"user","content":"Say OK."}]}'
# violates the country rule only
curl -s -o /dev/null -w '%{http_code}\n' localhost:8081/governed-llm/v1/chat/completions \
-H "Authorization: Bearer $PAT" -H 'content-type: application/json' \
-d '{"model":"acme-standard","max_tokens":8,"messages":[{"role":"user","content":"Say OK."}]}'
# violates the model rule only
curl -s -o /dev/null -w '%{http_code}\n' localhost:8081/governed-llm/v1/chat/completions \
-H "Authorization: Bearer $MARIA" -H 'content-type: application/json' \
-d '{"model":"claude-opus-4-1","max_tokens":8,"messages":[{"role":"user","content":"Say OK."}]}'
# no credential
curl -s -o /dev/null -w '%{http_code}\n' localhost:8081/governed-llm/v1/chat/completions \
-H 'content-type: application/json' \
-d '{"model":"acme-standard","max_tokens":8,"messages":[{"role":"user","content":"Say OK."}]}'
Expected: 200, 403, 403, 401. Anything else and one of the two bugs above is in your policy.
Running it
The demo lives in themsquared/agentic-demo
. It needs a Solo Enterprise license, since EnterpriseAgentgatewayPolicy is an enterprise CRD.
./setup.sh # k3d cluster, mesh, gateway, agents (~15 min)
./port-forward.sh
./governance-demo.sh --check
--check runs the whole governance walkthrough non-interactively and asserts 24 outcomes, including the four status codes above. The authorization act is --act 2 if you only want that part.
The policy discussed here is 02-ofac-model-allowlist.yaml
, and both gotchas are written into the file’s header comment so the next person to edit it does not re-derive them.
Next on this route: the same request body, read by a web application firewall instead of a policy engine, so the prompt itself gets inspected rather than just the claims around it.