Skip to main content

Author and promote tenant policies

Policy-as-Code is the governance lever that decides — per request, per model, per tenant — what calls are allowed, what is blocked, and what is downgraded. Your workspace owns its own policies; nothing you write here affects another customer's traffic, and the operator's platform-wide policies stay out of your way.

Before you start

Sign in as a tenant admin or developer and confirm the gateway-connected indicator is green. Active and shadow policies take effect on the next data-plane request.

Draft a policy in YAML

  1. Open Portal → Policies → New policy.
  2. Give the policy a name (e.g. block-pii-output), a one-line description, and write the rules in the YAML editor. The editor has syntax highlighting and validates structure as you type.
  3. Submit. The policy starts as DRAFT — it is saved and visible in the list, but it does not affect traffic.
Authoring mistakes surface immediately

The YAML is compiled at submit time, not when the policy is later activated. Unknown keys (condition: instead of conditions:, tenant: as a per-rule condition), unknown actions (action: warn or action: shadow instead of WARN_AGENT / DENY), and malformed YAML all fail the submit with 400 INVALID_POLICY_DSL and the compiler error pointing at the offending key or rule id. The policy is not saved, so a typo in the YAML never silently disables a rule.

Two distinctions worth keeping in mind while authoring:

  • tenant: is not a per-rule condition. Policies are tenant-scoped at the entity level — every policy you create here is automatically scoped to your workspace. There is no conditions: { tenant: X } syntax.
  • SHADOW is not a per-rule action. It is a policy-lifecycle status — flip the whole policy from DRAFT to SHADOW (below) to observe its decisions on real traffic without blocking. Rules accept exactly two actions: DENY and WARN_AGENT (case-insensitive).

A minimal policy looks like this:

version: "1"
rules:
- id: only-approved-models
priority: 10
conditions:
model:
allowlist: [gpt-4o, gpt-4o-mini, claude-sonnet-4-5]
action: DENY
deny_message: "Model not on the approved list"

Every rule has an id, a priority (lower number = higher precedence, default 100), a conditions block that matches request attributes (model, max_tokens, tools, data_residency, time_of_day, MCP server / tool / args, budget utilization), and exactly one actionDENY or WARN_AGENT. deny_message (for DENY) or warn_message (for WARN_AGENT) appears in the response. Conditions on model use allowlist or denylist. Rules evaluate in priority order; the first matching DENY returns immediately, while WARN_AGENT rules collect their warnings and continue.

When the closed-form conditions: aren't expressive enough — use expression:

For patterns that the closed-form matchers above can't compose — anything that needs OR, NOT, or arithmetic across fields — author the rule with an expression: field instead of conditions:. The value is a CEL (Common Expression Language) string that's compiled and type-checked at submit time. CEL is the same expression language Kubernetes admission policies, Envoy, Cilium, and gRPC use for the same problem class — sandboxed by construction (no I/O, no host calls, no loops), deterministic, and type-checked at compile.

version: "1"
rules:
- id: gpt4o-outside-eu-or-large-requests
expression: |
request.model == "gpt-4o" &&
(context.region != "EU" || request.message_count >= 50)
action: DENY
deny_message: "gpt-4o restricted outside EU or for >= 50 messages"

That same pattern in the closed-form conditions: would require three rules. With expression:, it's one.

Evaluation context (closed surface):

VariableTypeMeaning
request.modelstringThe requested model name
request.tenant_idstringThe resolved tenant id (empty when absent)
request.message_countintCount of messages on the request
request.total_input_charsintSum of message-content character lengths (coarse size proxy that doesn't expose the actual text)
request.metadata[key]map<string, string>Per-request metadata
context.regionstringThe gateway pod's own region (empty when unset)
context.tenant_regionstringThe tenant's home region (empty when unset)
budget.utilization_pctint (0–100)Tightest budget cap's utilization at evaluation time

The Type column shows the runtime type. At the CEL type-checker level, every request.* / context.* / budget.* field is declared as Dyn — the v1 evaluation surface uses dynamic typing so the surface can evolve without breaking compiled policies. The practical consequence: when you call a CEL string method on one of these fields, cast first with string(...). request.model.startsWith("gpt") will fail at compile with "no matching overload for startsWith applied to Dyn"; string(request.model).startsWith("gpt") compiles. Equality (==, !=), comparison (<, >, in, indexing m["k"]) work without a cast.

Plus the CEL standard library: string methods in receiver form — s.startsWith(t), s.endsWith(t), s.contains(t), s.matches(regex) — list/map indexing, the in operator, comprehensions (xs.all(x, …), xs.exists(x, …)). Use the receiver form, not the free function form — Nessie CEL (the library this build uses) only registers receiver-style overloads for the string methods, so matches(s, regex) fails to compile and s.matches(regex) is the working shape.

Rules to author by:

  • expression: and conditions: are mutually exclusive on the same rule. Specifying both fails the submit with INVALID_POLICY_DSL. Specifying neither also fails — a matcher-less rule is almost certainly an authoring mistake.
  • Compile-time validation is strict. Bad CEL syntax, references to undeclared variables (mystery_var.foo), and type mismatches all fail submit with the rule id + CEL diagnostic. Test in the dry-run panel first.
  • Cast Dyn fields before calling string methods. string(request.model).matches("^gpt-.*$"), not matches(request.model, "^gpt-.*$"). See the note above the stdlib list.
  • Static conflict detection is skipped for CEL rules. Equivalence between two arbitrary CEL expressions is undecidable in general, so the conflict detector emits one "skipped — verify manually via dry-run" warning per CEL rule at promotion time. Non-CEL rules in the same policy still get checked the normal way.
  • Temporal functions (now(), hour(ts, tz)) are not in v1. For time-of-day deny windows, use the existing closed-form time_of_day: matcher (under conditions:); a CEL temporal extension is on the roadmap.

Dry-run before you ship

The dry-run panel takes a single synthetic request and walks it through the policy you just drafted. It returns the decision (allowed: true | false, with any reason and the list of collected warnings), the rule that matched (if any), and the time taken to evaluate. Use it to verify that:

  • the rule you expect to fire actually fires
  • the rule you do not want to fire does not
  • the decision is what you expect

The dry-run is stateless — it does not store the request, it does not call an upstream model, and it always runs against your own workspace.

Ship to SHADOW for safe observation

A SHADOW policy evaluates on real production traffic but does not block anything. Every divergence — every time the policy would have changed the outcome — is recorded with the would-be decision and a sample of the matching request. After a day or two of shadow data you can tell whether the policy is doing what you intend or generating false positives.

To ship to SHADOW: open the policy, change status from DRAFT to SHADOW, submit. It now evaluates alongside your active policies on every matching request, silently.

Promote SHADOW to ACTIVE

When the shadow data tells you the policy is right:

  1. Open the policy and click Promote.
  2. The promote dialog summarises the rule set one last time. Confirm.
  3. The policy flips to ACTIVE. From the very next request, it is enforced; denials produce HTTP 403 with the rule's deny_message, and WARN_AGENT rules annotate the response with their collected warnings.

Promotion is reversible — set the status back to SHADOW or DRAFT at any time.

Roll back to a previous version

Every save creates a new version (your last 10 versions are kept). To roll back: open the policy, click Versions, pick the version you want to restore, and confirm. The current version is replaced with the chosen one, the version counter increments, and the change is audited.

What you cannot change from this page

By design, the portal will not let you:

  • See or edit another customer's policies — they are simply not in the list.
  • See or edit the operator's platform-wide policies — they apply to your workspace but are managed by whoever runs DVARA Cloud. If a platform policy is denying calls you think it should not, escalate to your operator; they have their own change-control around it.
  • Stamp a different tenant id on a policy you create — every policy you author is automatically scoped to your own workspace.

What every action writes to the audit trail

Every mutation on this page lands in the audit log:

ActionAudit event
Create a policyPOLICY_CREATED
Update a policyPOLICY_UPDATED
Change statusPOLICY_STATUS_CHANGED (carries the new status in detail)
Promote SHADOW to ACTIVEPOLICY_PROMOTED
Roll back to a previous versionPOLICY_ROLLED_BACK (carries v<n> in detail)
Delete a policyPOLICY_DELETED

Data-plane decisions write their own events: POLICY_DENIED when an active policy blocks a call, POLICY_SHADOW_DENY when a shadow policy would have denied. Filter the audit log by these events to read what your policies have actually done.

The Portal Policies list for tenant Acme Inc, ready for the first DRAFT policy to be authored.The Portal Policies list for tenant Acme Inc, ready for the first DRAFT policy to be authored.

Figure 1. The Policies list. Status, version, and last-updated columns let you spot active versus shadow versus draft at a glance.

The Portal New Policy form for tenant Acme Inc, with the YAML editor ready for the first rule set.The Portal New Policy form for tenant Acme Inc, with the YAML editor ready for the first rule set.

Figure 2. The New Policy form. The YAML editor validates structure as you type; submit lands the policy in DRAFT.

Next steps