# Control surface access with an OPA policy

> Write a Rego policy definition and attach it to a surface, so requests are allowed or denied based on claims, request attributes, and organisational rules.

This guide writes an llm-typed Rego policy definition and attaches it to a surface’s OPA Policy block, so every request is evaluated against a rule you author, on top of whatever source authentication already checked. For the conceptual model, see [OPA policies →](/products/affinidi-trust-fabric/agent-stream/concepts/opa-policies.md).

Source authentication proves a caller presented a valid credential; it says nothing about whether that specific caller, claim, or context should be allowed to use this particular surface. Without a policy, every authenticated caller gets identical access.

Use this guide when:

- Access should depend on a claim value, request attribute, or organisational rule, not just “is this credential valid.”

- Different callers on the same surface need different access decisions, for example by tenant, role, or department.

- You need every access decision recorded against the exact policy version that produced it, for audit.

You do not need a policy if accepting or rejecting a credential is the whole access decision; [source authentication](/products/affinidi-trust-fabric/agent-stream/how-to-guides/security/validate-bearer-tokens-on-a-surface.md) alone already covers that.

## Prerequisites

- An active LLM Surface with source authentication configured, so input.source_auth is populated for the policy to evaluate.

- Administrator role in the dashboard; policy management (viewing, creating, editing, deleting, and publishing a policy) is administrator-only by default.

## Steps

Write the policy definition

Select Policies in the dashboard sidebar, switch to the LLM tab, and select Define LLM Policy. Set:

| Field | Value for this guide |
| Name | A descriptive label, for example require-known-tenant. |
| Type | llm, so the policy applies to LLM Surfaces. Its Rego must declare package llm.policy. Type is locked once you save. |
| Policy Content (Rego) | The Rego source (below). |

```rego
package llm.policy

default allow = false

allow if {
  input.source_auth.claims.tid == ""
}

allow_reason := "Known tenant" if allow
deny_reason := "Caller's tenant claim did not match the allowed tenant" if not allow
```

allow_reason and deny_reason are optional companion rules; when present, they flow into policy audit logs and observability as a human-readable reason alongside the allow/deny decision.

Save the policy definition. A handful of Rego builtins are rejected at write time regardless of layer: numbers.range(, http.send(, opa.runtime(, and print(. If your save fails, confirm the policy does not call any of these.

Dry-run it against a sample input

Reopen the saved policy definition and use its Test (dry-run) panel: paste a representative input document and run it to confirm the policy produces the decision you expect before it is ever enforced against real traffic.

Attach the policy to the surface

Under SURFACES in the dashboard sidebar, select LLM, open your surface, and add (or open) its OPA Policy element. The element’s presence on the canvas is what enables it; set Policy Definition to the definition you just saved.

Optional: combine with more than one policy

A surface’s OPA Policy element attaches exactly one policy definition from the dashboard. To evaluate more than one policy against this surface, enforce the additional policy or policies globally instead: see [Roll out an OPA policy globally →](/products/affinidi-trust-fabric/agent-stream/how-to-guides/policies/roll-out-an-opa-policy-globally.md). A global assignment is ANDed with this surface’s own attached policy, deny-overrides: each one must independently return allow = true for the request to proceed, and the first to deny blocks it.

Optional: log decisions while testing

Turn on Log OPA decisions to emit a log line for every OPA evaluation on this surface, carrying the full input and the resulting allow/deny. This is a debugging aid only: leave it off in production, since input carries request headers and caller identity.

Save the surface

Select the save icon in the toolbar. The change applies immediately; no restart is required.

## Confirm

Replace <YOUR_APPLIANCE_HOST> and <YOUR_SURFACE_ROUTE> with your surface’s values, and present a caller token whose tid claim matches or does not match your policy’s expected tenant.

### Test 1: a request from the allowed tenant returns 200

```bash
curl -k -X POST "https:///v1/chat/completions" \
  -H "Authorization: Bearer " \
  -H "Content-Type: application/json" \
  -d '{
    "messages": [
      { "role": "user", "content": "Say hello." }
    ]
  }'
```

The -k flag disables TLS certificate verification. Use this for local testing only. Remove it in production.

Expected: 200 OK. Source authentication passed, and the policy’s tid check allowed the request.

### Test 2: a request from a different tenant returns 403

Resend the same request with a token whose tid claim does not match. Expected: 403 Forbidden. If Log OPA decisions is on, the surface’s request log shows the deny_reason text alongside the denial.

## Troubleshooting

| Symptom | Likely cause | Fix |
| Every request is denied, including ones that should pass. | The policy’s condition has a typo, or default allow = false is never satisfied for a real request’s actual claim shape. | Re-run the Test (dry-run) panel against a real sample input captured with Log OPA decisions, and compare the claim path used in the rule against what the token actually carries. |
| Saving the policy fails with a compile error. | The Rego uses a rejected builtin (numbers.range(, http.send(, opa.runtime(, or print(), or exceeds the fixed source-size limit. | Rewrite the rule without the rejected builtin, and split an overly large policy into smaller definitions if it hits the size limit. |
| A globally-enforced policy and this surface’s own policy are both attached, and the request is denied even though the surface’s own policy should allow it. | The two are combined deny-overrides: every applicable policy must allow. The global assignment still denies the request independently. | Check the global policy’s own dry-run result too, not just this surface’s attached policy. |
| A policy that used to allow a request now denies it, with no config change. | The sample input you tested against does not reflect a change in the real caller’s claims (for example, a token issuer rotated a claim’s shape). | Compare a freshly captured real input (via Log OPA decisions) against the sample input you last tested, and update it to match. |

## Next steps

- [Roll out an OPA policy globally with monitor-only mode](/products/affinidi-trust-fabric/agent-stream/how-to-guides/policies/roll-out-an-opa-policy-globally.md): Enforce this same policy across every LLM surface instead of attaching it one at a time.

- [Verify caller credentials with VP evidence](/products/affinidi-trust-fabric/agent-stream/how-to-guides/policies/verify-caller-credentials-with-vp-evidence.md): Gate this policy on a cryptographically verified credential, not just JWT claims.

- [Attribute usage to members and teams](/products/affinidi-trust-fabric/agent-stream/how-to-guides/teams/attribute-usage-to-members-and-teams.md): Combine policy-based access control with per-caller quotas.

## Related

- [OPA policies reference](/products/affinidi-trust-fabric/agent-stream/reference/policies/opa-policies.md): The full input document, all four evaluation layers, and the policy-definition record shape.

- [Guardrails](/products/affinidi-trust-fabric/agent-stream/concepts/guardrails.md): Content-safety checks that run alongside policy in the request pipeline.

- [Security and access control](/products/affinidi-trust-fabric/agent-stream/concepts/security-and-access-control.md): How source authentication establishes the identity this policy evaluates.
