Skip to main content

Recipe: Multi-tenant isolation

The problem

You run agents for multiple teams (or multiple customer tenants) on shared infrastructure. An agent operating for Team A must never read, write, or trigger work against a Team B resource -- even if a misconfiguration wires the wrong policy to the wrong project.

The policy

version: '1'
# Multi-tenant isolation.
#
# Every signed policy bundle is already org-scoped -- it will never
# be served to a device enrolled under another org. This recipe adds
# an explicit project-scoped allow inside the bundle so an
# accidentally-attached policy still refuses cross-tenant work.
#
# The `when:` block matches the `project` tag that the SDK attaches
# to every guard() call. Calls with the wrong project never satisfy
# the allow rule and fall through to default_action deny.
settings:
default_action: deny
default_on_missing: deny
default_on_tamper: deny
rules:
- id: allow-team-a-data-read
allow: 'data:read'
when:
project: 'team-a*'
reason: 'Team A agents may read Team A data.'
- id: allow-team-a-data-write
allow: 'data:write'
when:
project: 'team-a*'
reason: 'Team A agents may write Team A data.'
- id: allow-team-a-llm
allow: 'llm:call'
when:
project: 'team-a*'
reason: 'Team A agents may call vetted LLMs.'

Why it works

Three layers of defense stack here:

  1. The signed bundle is org-scoped. An enrolled device under org_id: team-a-holdings will never receive a bundle built for any other org.
  2. Inside the bundle, each allow rule gates on the project tag. The SDK's guard() wrapper attaches the current project to the call context; the policy layer matches it against when: project:.
  3. default_action: deny means any call whose project tag doesn't match (or is missing) falls through to a deny, emitted with reason_code: NO_RULE_MATCH.

What gets blocked

Agent callProject tagExtracted actionDecisionreason_code
data readteam-b-researchdata:readdenyNO_RULE_MATCH
data writeteam-b-researchdata:writedenyNO_RULE_MATCH
data read(missing)data:readdenyNO_RULE_MATCH
llm callteam-b-researchllm:calldenyNO_RULE_MATCH

What gets allowed

Agent callProject tagExtracted actionDecisionreason_code
data readteam-a-financedata:readallowRULE_MATCH
data writeteam-a-financedata:writeallowRULE_MATCH
llm callteam-a-financellm:callallowRULE_MATCH

Test it yourself

curl -O https://docs.controlzero.ai/recipes/multi-tenant-isolation/policy.yaml
curl -O https://docs.controlzero.ai/recipes/multi-tenant-isolation/scenarios.json

controlzero test-policy policy.yaml --scenarios scenarios.json

Caveats

  • This recipe assumes the caller is honest about the project tag. Guard that assumption at the SDK wrapper layer: the project tag should be set once at agent start from the environment (or the enrollment record) and not be re-settable by the agent's own tools. If the agent can set its own project tag, it can vote itself out of isolation.
  • "Team A can read Team A dataset X but not Team A dataset Y" needs a second condition on resource. Add when: project: team-a* AND resource: dataset/team-a/public/* to a rule to scope further.
  • The default eval engine has no !team-a* negation pattern. You cannot write "deny if project is NOT team-a". Instead, write allow-only rules with when: project: team-a* and let default_action: deny pick up everything else. If you need a hard explicit cross-tenant deny entry for audit traceability, keep it as a wildcard allow (project: "*") on an audit:cross_tenant_attempt synthetic action and wire your audit ingest to alert on it.
  • Policy attachment (hosted mode) is the authoritative control. Before debugging cross-tenant leaks with YAML, confirm the wrong policy is not simply attached to the wrong project in the dashboard.