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.
What you'll build
- 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.
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.
# 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.
# 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.
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
The argument-level rules that ride on top of tenant isolation.
Shadow-test policiesPair version pins with shadow mode for safer per-tenant rollouts.
RBAC for AI agentsThe role layer that sits orthogonal to tenant isolation.
AI agent permissionsPolicy model behind tenant isolation.
Multi-tenant AI agentsLong-form on why tenant boundaries belong in policy, not SQL.
Bind the tenant once. Each governed tool call inherits the boundary.