Implementation guide

How to set up multi-tenant AI agent isolation

A multi-tenant agent product has a quiet problem: one customer's prompt can ask the agent to touch another customer's data, and the SQL layer is the last line of defense. The right place to draw the boundary is the tool-call policy, where the tenant id lives in context and every rule can compare it to the args. Wire the tenant id through the decision call, writes a baseline isolation policy, layers per-tenant overrides on top, and pins policy versions per tenant so changes release in stages. The pattern works at one tenant or one thousand.

  • A tenant-id propagation pattern that carries the tenant through every veto.decide call.
  • A baseline isolation policy that denies cross-tenant reads, writes, and missing-context calls.
  • Per-tenant overrides that extend the baseline without forking it.
  • Per-tenant policy versioning so you can roll out changes to one customer first.

Step 1: Propagate the tenant id

The tenant id should never come from the model. It comes from the request that started the agent session, lives in context, and ride each governed tool call. The agent loop passes it to veto.decide without the model ever touching it. The project tag (project="tenant_regulated_01") gives you a per-tenant policy folder in the workspace and clean per-tenant filtering in the decision record.

py
import os
from veto_sdk import Veto

veto = Veto(
    api_key=os.environ["VETO_API_KEY"],
    organization_id=os.environ["VETO_ORG_ID"],
)

def run_agent_for_tenant(tenant_id: str, user_id: str, agent_id: str, prompt: str):
    context = {
        "tenant_id": tenant_id,
        "user_id": user_id,
        "request_id": str(uuid.uuid4()),
    }

    return agent.run(
        prompt,
        on_tool_call=lambda tool, args: veto.decide(
            tool=tool,
            args=args,
            agent={"id": agent_id, "tenant_id": tenant_id},
            context=context,
            project=f"tenant_{tenant_id}",
        ),
    )

The agent itself does not see the tenant id; the agent sees the prompt, the tool schemas, and the outputs of allowed tool calls. The tenant id stays out-of-band in the context object, which is exactly the right place for it.

Step 2: Write the baseline isolation policy

Three rules cover most tenants. First, deny each governed tool call that arrives without a tenant id in context: this catches misconfigured wrappers before they leak. Second, deny any read where an explicit args.tenant_id disagrees with the context tenant id. Third, do the same for writes. These rules apply to every tenant; tenant-specific rules layer on top.

yaml
# policies/tenant-isolation.yaml
- name: every_tool_call_carries_tenant_id
  match:
    tool: "*"
  rules:
    - if: context.tenant_id == null
      then: deny
      reason: "missing tenant_id in request context"

- name: tenant_scoped_reads
  match:
    tool: "*"
    pattern: "^(search|get|list|query)_"
  rules:
    - if: args.tenant_id != null and args.tenant_id != context.tenant_id
      then: deny
      reason: "cross-tenant read attempted"

- name: tenant_scoped_writes
  match:
    tool: "*"
    pattern: "^(create|update|delete|send)_"
  rules:
    - if: args.tenant_id != null and args.tenant_id != context.tenant_id
      then: deny
      reason: "cross-tenant write attempted"
    - if: tenant.plan == "free" and tool == "send_email"
      then: require_approval

The pattern clause uses a regex so the same rule applies to search_orders,get_user, and list_invoices without enumerating each. New tools that follow the naming convention pick up the rule through the same policy path.

Step 3: Layer per-tenant overrides

Some tenants have rules nobody else does. One priority tenant has a policy addendum that needs human review for date-of-birth and address columns. Widgets, Inc. blocks all external email. These live in per-tenant files that extends the baseline instead of forking it. The baseline rules stay in one file; tenant-specific rules sit on top.

yaml
# policies/per-tenant/tenant-regulated-01.yaml
extends: tenant-isolation

- name: tenant_customer_pii_extra_rules
  match:
    tool: query_users
  rules:
    - if: contains_any(args.columns, ["date_of_birth", "address"])
      then: require_approval
      reason: "priority tenant policy addendum: DOB and address require human review"

- name: tenant_customer_higher_row_caps
  match:
    tool: search_orders
  rules:
    - if: args.limit > 500
      then: deny
    - then: allow

# policies/per-tenant/widgets-inc.yaml
extends: tenant-isolation

- name: widgets_inc_no_external_email
  match:
    tool: send_email
  rules:
    - if: not args.to.endsWith("@widgets-inc.invalid")
      then: deny
      reason: "widgets-inc has no external email"

Per-tenant policies live in policies/per-tenant/<tenant-slug>.yaml and the project tag from step 1 picks the right file at decision time. New tenants get the baseline by default; only the customers with addenda need a file.

Step 4: Pin policy versions per tenant

A multi-tenant rollout should not be "release to all customers at once". The decide call takes an explicit policy_version pin, and the version map gives you one-customer-at-a-time control. Bump that tenant to v24 for a week, watch the decision record, then promote to the others. If a rule misfires for one tenant, you roll that tenant back without touching anyone else.

py
import os
from veto_sdk import Veto

veto = Veto(api_key=os.environ["VETO_API_KEY"])

TENANT_POLICY_VERSIONS = {
    "tenant-regulated-01": "v23",
    "widgets-inc": "v17",
    "default": "v25",
}

def decide_for_tenant(tenant_id: str, tool: str, args: dict, agent_id: str):
    version = TENANT_POLICY_VERSIONS.get(tenant_id, TENANT_POLICY_VERSIONS["default"])

    return veto.decide(
        tool=tool,
        args=args,
        agent={"id": agent_id, "tenant_id": tenant_id},
        context={"tenant_id": tenant_id},
        project=f"tenant_{tenant_id}",
        policy_version=version,
    )

Pair version pins with shadow mode to preview a new version for one tenant before it enforces. The pattern is the same: bump the canary tenant first, watch the shadow-vs-enforce diff, then promote.

Failure modes to catch

Tenant id read from the prompt

The tenant id comes from the authenticated session, not the prompt. A prompt that says "use tenant customer_01" should not change which tenant the policy enforces. Pin it once in context and never accept it from the model.

Forking per-tenant policy files

Copying the baseline into every per-tenant file produces drift within a quarter. Use the extends keyword and keep the baseline as the single source for the shared rules.

No version pin in the decide call

Without an explicit policy_version, a rule change applies to every tenant at once. Pin the version in the decide call and use the per-tenant map to control the rollout.

Production checklist

  • Every veto.decide call carries context.tenant_id pulled from the authenticated session.
  • Baseline policy denies missing-tenant calls and cross-tenant args on reads and writes.
  • Per-tenant overrides extend the baseline instead of copying its rules.
  • Policy versions are pinned per tenant in code, not implicit.
  • Onboarding a new tenant creates a project tag, no policy file required unless they have addenda.

FAQ

Do I need a separate Veto organization per tenant?

No. One Veto organization holds all your tenants, and each tenant maps to a project inside that organization. The decision call carries the tenant id as both a project tag and a context field, which gives you per-tenant policy folders without fragmenting auth, billing, or audit. Use separate Veto organizations only when you have separate businesses on the same Veto account, not for each customer.

How do I keep tenant policies from drifting from the baseline?

Use the extends keyword in YAML so per-tenant policies inherit from a shared baseline. Customer-specific rules sit on top as overrides; the shared rules stay in one file. Combine this with CI checks that diff every per-tenant policy against the baseline and fail the PR if a tenant accidentally weakens the baseline.

Can I roll out a policy change to one tenant before the rest?

Yes, that is what the policy_version pin in the decide call is for. Bump the version map for one tenant, run for a week, then promote to the others. Shadow mode pairs with version pins so you can preview the change for the canary tenant before it enforces.

Related guides

Bind the tenant once. Each governed tool call inherits the boundary.