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:
- The signed bundle is org-scoped. An enrolled device under
org_id: team-a-holdingswill never receive a bundle built for any other org. - 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 againstwhen: project:. default_action: denymeans any call whose project tag doesn't match (or is missing) falls through to a deny, emitted withreason_code: NO_RULE_MATCH.
What gets blocked
| Agent call | Project tag | Extracted action | Decision | reason_code |
|---|---|---|---|---|
data read | team-b-research | data:read | deny | NO_RULE_MATCH |
data write | team-b-research | data:write | deny | NO_RULE_MATCH |
data read | (missing) | data:read | deny | NO_RULE_MATCH |
llm call | team-b-research | llm:call | deny | NO_RULE_MATCH |
What gets allowed
| Agent call | Project tag | Extracted action | Decision | reason_code |
|---|---|---|---|---|
data read | team-a-finance | data:read | allow | RULE_MATCH |
data write | team-a-finance | data:write | allow | RULE_MATCH |
llm call | team-a-finance | llm:call | allow | RULE_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
projecttag. 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. Addwhen: project: team-a*ANDresource: 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 withwhen: project: team-a*and letdefault_action: denypick up everything else. If you need a hard explicit cross-tenant deny entry for audit traceability, keep it as a wildcard allow (project: "*") on anaudit:cross_tenant_attemptsynthetic 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.