Runbook: role binding
How Shield decides which role an authorization decision uses, how to make that role unforgeable, and how to verify it rather than assume it.
Table of contents
Pick a mode
SHIELD_ROLE_BINDING takes four values.
| mode | verified claim present | no verified claim | use when |
|---|---|---|---|
off (default) |
header wins | header wins | Shield is unreachable except from trusted callers |
prefer |
claim wins | header wins | migrating; you want the audit before you enforce |
strict |
claim wins | no role | every caller can present a verified credential |
strict_proxy |
claim wins | header accepted only from the trusted proxy | Shield is public, but internal callers legitimately send a role header |
The one thing to internalize: prefer is not a control on a public
endpoint. It upgrades callers that present a credential, and does nothing
about a caller that presents none. This still works under prefer:
curl -s "$SHIELD/guardrails/input" -H 'X-User-Role: sre_lead' -d '{"prompt":"restart checkout-api"}'
There is no verified claim, so there is nothing to prefer, so the header wins.
Verify before you trust
Run these against the deployment, not a local instance. Thirty seconds, and they are the whole claim.
The negative test — must be refused under strict or strict_proxy:
curl -s "$SHIELD/guardrails/input" -H 'X-User-Role: sre_lead' -H "X-API-Key: $TENANT_KEY" -d '{"prompt":"restart checkout-api"}'
The positive test — must succeed under strict_proxy:
curl -s "$SHIELD/guardrails/input" -H 'X-User-Role: sre_lead' -H "X-Shield-Proxy-Token: $PROXY_SECRET" -H "X-API-Key: $TENANT_KEY" -d '{"prompt":"restart checkout-api"}'
If the first one succeeds, role binding is not enforcing. The most common causes, in order:
SHIELD_ROLE_BINDINGis unset oroffon the deployment you are testing.- Set to
strict_proxywhileSHIELD_TRUSTED_PROXY_ONLYis unset — the mode is inert by design, so it behaves asstrict. Check the negative test is refused; if it is, this is not your problem. - The tenant has an overriding config.
SHIELD_ROLE_BINDING=offis the operator kill switch and beats any tenant setting, but a tenant setting beats any other env value.
Check what the decision actually recorded. The audit carries
role_source, role_verified and role_binding_mode on every decision:
role_source |
meaning |
|---|---|
oidc / agent_token / mtls |
proven — a signature Shield checked |
proxy |
vouched — a trusted hop asserted it, nobody proved the user |
header / body |
claimed — the caller said so |
none |
refused or absent |
role_verified is true only for the first row. A proxy role is deliberately
not “verified”, and any dashboard that treats it as such is wrong.
Configure the IdP claim path (per tenant)
Which claim carries the role differs per IdP. Guessing fails silently: a wrong path yields no roles, binding falls back, and the symptom looks like “binding is broken” rather than “the path is wrong”.
Ask for the known-good paths rather than guessing:
curl -s "$SHIELD_ADMIN/v1/tenant/me/identity/role-binding/presets" -H "X-API-Key: $TENANT_KEY"
| IdP | role_claim |
|---|---|
| Keycloak | realm_access.roles (the default) |
| Okta | groups |
| Entra ID | roles (app roles; directory groups appear as groups) |
Set the path, map IdP group names onto Shield role names, and list the roles Shield should accept:
curl -X PUT "$SHIELD_ADMIN/v1/tenant/me/identity/role-binding" -H "X-API-Key: $TENANT_KEY" -H 'Content-Type: application/json' -d '{"role_claim":"groups","role_map":{"bank-payments-officers":"payments_officer"},"role_allowlist":["payments_officer","auditor"]}'
Set
role_allowlistif you are using Okta or Entra. Their tokens carry every group the user belongs to —all-employees,vpn-users,printer-access. Without an allowlist, the first group in the token is taken as the role, purely because it was first. The allowlist names Shield role names and is applied afterrole_maprenames them. An empty allowlist filters nothing, so this is opt-in.
Namespaced claims (Auth0, custom Okta)
Auth0 and custom Okta claims are namespaced URLs, which contain dots the path reader would otherwise treat as nesting. Bracket-quote the segment to take it literally:
{"role_claim": "[\"https://votal.ai/roles\"]"}
Bracket and dotted segments mix: resource_access.["my.app"].roles.
Read it back to see whether it is actually taking effect:
curl -s "$SHIELD_ADMIN/v1/tenant/me/identity/role-binding" -H "X-API-Key: $TENANT_KEY"
effective_mode and env_kill_switch are the fields to look at.
SHIELD_ROLE_BINDING=off overrides tenant config globally, so a tenant can have
prefer stored and see header roles still winning. That asymmetry is otherwise
invisible.
One claim path, both features
SHIELD_ROLE_CLAIM is the deployment-wide fallback and now applies to both
role binding and delegation. It previously applied only to delegation, while
role binding hard-coded Keycloak’s realm_access.roles — so pointing a
deployment at Okta fixed one path and silently not the other.
If your deployment already sets SHIELD_ROLE_CLAIM and has role binding
enabled, role binding will now honour it. That is the fix, but it is a change:
verify with the audit that role_source is oidc where you expect it, rather
than assuming.
Resolution order is tenant config, then SHIELD_ROLE_CLAIM, then
realm_access.roles.
Two behaviours to know:
- Changes take up to 30 seconds fleet-wide. Each replica caches the config for that long. The write drops the cache on the replica that served it; the others expire on their own.
- A tenant may strengthen role binding, not weaken it. If the deployment is
strict, a tenant write ofofforpreferis refused with422. Ordering isoff<prefer<strict_proxy<strict. This applies to writes made through the API; configs already in Redis are read as-is.
Configure the trusted-proxy boundary
Only needed for strict_proxy.
Generate a high-entropy secret:
openssl rand -base64 48
On Shield:
SHIELD_ROLE_BINDING=strict_proxy
SHIELD_TRUSTED_PROXY_ONLY=true
SHIELD_TRUSTED_PROXY_SECRET=<the secret>
SHIELD_TRUSTED_PROXY_IPS=10.0.0.0/8 # optional, further constrains
The proxy sends the secret as X-Shield-Proxy-Token on every request to
Shield.
Why a secret and not an IP allowlist. Under uvicorn’s proxy_headers,
request.client.host is derived from the caller-controlled X-Forwarded-For
header and is therefore spoofable. The secret is authoritative when set; the IP
list, if also set, further constrains. With neither configured, trusted-proxy
mode trusts nobody — fail-closed.
Blast radius. Anything holding this secret can assert any role for any
tenant it can route to. That is the same authority the secret already carries
for mTLS-derived identity and for DPoP htu rewriting. Treat it as a
deployment-level trust root: injected by the proxy only, never reachable from a
client-settable header path, rotated on suspicion.
Rotation. Shield compares against a single secret, so rotation is a brief
two-step: set the new secret on Shield and the proxy in the same change window,
or accept a window where strict_proxy falls back to strict behaviour
(callers with verified claims keep working; header-only callers lose their
role).
Rollout order
Configure the proxy to inject X-Shield-Proxy-Token before switching
SHIELD_ROLE_BINDING to strict_proxy.
Reversing the order fails closed, which is correct, but it drops every
header-derived role for the length of the window. Rollback is a single env
change: SHIELD_ROLE_BINDING=off. No data migration, no key cleanup.
Shield behind an LLM gateway (LiteLLM)
When Shield sits behind LiteLLM, the guardrail hook is Shield’s HTTP client, not the agent. Only what the hook forwards survives, so role binding on that path is exactly as strong as the hook’s configuration.
Three tiers, and they compose without configuration — Shield already resolves in this order.
Tier 1 — verified. Preferred.
The agent sends its OIDC user token to LiteLLM as X-On-Behalf-Of. The hook
forwards it; Shield verifies the signature against the issuer’s JWKS.
A bearer user token, unlike a DPoP proof, is not bound to the HTTP request, so
verification survives the gateway hop intact. This is what makes strict
viable behind LiteLLM — the role is proven, not vouched, and no proxy trust is
involved.
On Shield:
SHIELD_DELEGATION=optional
SHIELD_WORKLOAD_OIDC_ISSUERS=https://idp.example.com/realms/shield
SHIELD_WORKLOAD_OIDC_AUDIENCE=shield
SHIELD_ROLE_CLAIM=realm_access.roles
SHIELD_WORKLOAD_OIDC_ISSUERSmust exactly match theissyour IdP puts in the token. An issuer mismatch means verification silently returns nothing and the role falls back — the failure looks like “role binding is broken” and is almost always this. Decode a real token and compareissbyte for byte before blaming anything else.
Tier 2 — vouched. For callers with no human user token.
CI bots and service-to-service traffic that authenticate to LiteLLM by virtual key alone.
On LiteLLM:
VOTAL_SHIELD_PROXY_TOKEN=<same value as SHIELD_TRUSTED_PROXY_SECRET>
The role must be on the virtual key, not in a header. The hook reads
metadata.shield_user_rolefrom the LiteLLM key the caller actually authenticated with. Provision it when you create the key:curl -X POST "$LITELLM/key/generate" -H "Authorization: Bearer $LITELLM_MASTER_KEY" \ -d '{"metadata": {"shield_user_role": "ci_bot"}}'A key with no
shield_user_rolesends no role, and understrict_proxythat caller gets no grants. This is the step most likely to be missed in a rollout.
When VOTAL_SHIELD_PROXY_TOKEN is set, the hook stops forwarding both the
caller’s x-user-role header and any metadata.user_role in the request body.
Both are caller-supplied — a client puts the second there with
extra_body.metadata — and asserting the hop is trusted while forwarding
either would launder the forgery through a hop Shield trusts.
That means under Tier 2 the role comes from the virtual key and nowhere else. A
caller that genuinely has a user identity should use Tier 1 instead: send the
OIDC token as X-On-Behalf-Of and the role is proven rather than vouched,
which outranks every source here.
Note the hook does not read LiteLLM’s UserAPIKeyAuth.user_role. That field
is LiteLLM’s admin model (proxy_admin, internal_user), not your RBAC role.
Tier 3 — nothing. No role, per strict.
What proof-of-possession does and does not cover here
Agent-token possession proofs (docs/spec-agent-token-pop.md) cannot work
through a gateway: DPoP binds a proof to the method and URI of the request, the
agent signs for POST /v1/chat/completions at LiteLLM, and Shield receives
POST /guardrails/input. The agent cannot sign for a request it does not make.
On that path the gateway authenticates itself with the shared secret and the
audit records that possession was vouched for rather than proven. The direct
paths — including cap/mint and tools/call, the endpoints that actually
authorize an action — are unaffected.
Troubleshooting
| symptom | cause |
|---|---|
| Forged header still works | mode is off or prefer; prefer is not a control |
Every request lost its role after enabling strict_proxy |
proxy is not injecting X-Shield-Proxy-Token, or SHIELD_TRUSTED_PROXY_ONLY is unset |
| Roles work directly but not behind LiteLLM | hook is not forwarding X-On-Behalf-Of, or the virtual key has no shield_user_role |
role_source: header when you expected oidc |
issuer mismatch — decode the token and compare iss to SHIELD_WORKLOAD_OIDC_ISSUERS |
| One tenant behaves differently | tenant config at shield:role_binding:{tenant_id} overrides the env value unless the env is off |
Role is used but role_verified is false |
it came from the trusted proxy; that is correct and intended |
Reference
- Agent governance — ownership, environment scoping, delegation visibility
- Spec: proxy-trusted role header — design and rationale
- Spec: IdP role-claim configuration — Okta, Entra, Google claim paths
- FAQ: verified identity — what Shield proves and what it does not