I have been thinking about a fairly ordinary failure mode in AI-enabled SaaS products.
A support agent is helping a user from tenant-a. The user asks about a contract, and the model produces a perfectly valid tool call:
get_contract("contract-b")
There is nothing obviously wrong with the call. The tool exists and the argument has the right shape. The problem is that contract-b belongs to tenant-b.
A better prompt might make this happen less often. It cannot turn the prompt into an authorization boundary.
I made TenantInvariant, a small experimental Rust crate, to explore where that boundary should live.
subaru-hello
/
tenant-invariant
Executable tenant-isolation invariants for AI agent tool calls, written in Rust.
TenantInvariant
What if an AI agent chooses a resource ID that belongs to another customer?
tenant-invariant is an experimental Rust library for making tenant isolation an executable invariant before an AI agent's tool call executes. The application supplies an authenticated actor and server-resolved resource ownership. The library allows same-tenant access and fails closed for cross-tenant or unknown ownership.
It does not trust tenant IDs produced by the model.
agent proposes resource ID
-> server resolves the resource owner from a trusted source
-> TenantInvariant compares actor and owner
-> existing authorization and tenant-scoped data operation
Setup
You need Git and the stable Rust toolchain. If Rust is not installed, use rustup, the installer recommended by the Rust project:
# macOS, Linux, or WSL
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
On Windows, download and run rustup-init.exe from the same official installation page. Restart the terminal…
The bug I had in mind
Tool calling adds a new decision-maker to an otherwise familiar request path:
user prompt
-> model chooses a tool
-> model supplies resource IDs
-> application executes the tool
It is tempting to put both tenant_id and contract_id in the tool schema. That gives the model a neat, self-contained payload:
{
"tenant_id": "tenant-a",
"contract_id": "contract-b"
}
It also gives the model a say in something it should not control. The tenant might have come from a system prompt, a previous tool result, or text supplied by the user. None of those sources prove authority.
The rule I settled on is simple: the model can propose a resource ID, but it cannot tell the application who owns that resource.
The actor's tenant comes from the authenticated server-side context. The application resolves the owner of the proposed resource from its database or another trusted service. Only those two values are compared.
AI proposes a resource ID
|
v
server resolves the real owner
|
v
authenticated tenant == resource owner?
| yes | no / unknown
v v
normal authorization continues deny
I chose to deny an unknown owner as well. If the lookup fails, there is not enough information to authorize the operation.
The crate is deliberately small
The actual comparison could be an if statement. I did not want to hide that fact behind a large abstraction.
The useful part of making it a crate is that the rule gets a name and a few types. TenantId rejects an empty identifier. ResourceOwner::Unknown makes a failed ownership lookup visible. Decision makes the caller deal with both the allowed and denied paths.
use tenant_invariant::{
check_tenant, Actor, Decision, ResourceOwner, TenantId,
};
fn main() {
let actor = Actor {
// This value comes from the authenticated application context.
tenant: TenantId::new("tenant-a")
.expect("tenant ID must not be empty"),
};
// The server looked this up using the ID proposed by the model.
let owner = ResourceOwner::Tenant(
TenantId::new("tenant-b")
.expect("tenant ID must not be empty"),
);
match check_tenant(&actor, &owner) {
Decision::Allow => {
// Continue with normal authorization and a scoped query.
}
Decision::Deny(reason) => {
eprintln!("blocked: {reason:?}");
}
}
}
You can install it from crates.io:
cargo add tenant-invariant
There is also a runnable example in the repository:
$ cargo run --example contract_lookup
blocked: CrossTenant
Rust works nicely here because invalid or unresolved states can be represented directly instead of being hidden in strings and null values. That does not make the system secure by itself, but it makes the intended control flow harder to ignore.
Tests I cared about
The first tests were the obvious ones: a resource in the same tenant is allowed, a resource in another tenant is denied, and an unknown owner is denied.
I also added scenarios for a forged tenant claim in model-generated arguments and for a batch containing resources from different tenants. The batch case is easy to get wrong. Checking the first valid item must not authorize the rest of the batch.
The more interesting test is a property test. Instead of choosing a few tenant names by hand, proptest generates pairs and checks the invariant directly: if the tenant IDs differ, the decision must always be CrossTenant.
proptest! {
#[test]
fn different_tenants_are_always_denied(
a in "[a-z0-9]{1,12}",
b in "[a-z0-9]{1,12}",
) {
prop_assume!(a != b);
let actor = Actor { tenant: tenant(&a) };
let owner = ResourceOwner::Tenant(tenant(&b));
prop_assert_eq!(
check_tenant(&actor, &owner),
Decision::Deny(DenyReason::CrossTenant),
);
}
}
For this kind of library, the property is almost the product.
Where this sits next to OpenAI and Claude
Both OpenAI and Anthropic already have controls around agent tools, so I wanted to understand whether this crate was duplicating them.
OpenAI's Agents SDK has tool guardrails, tool filtering, and approval flows. A tool input guardrail can reject a call just before a custom function tool runs. That is a natural place to invoke a tenant check. OpenAI also notes that not every hosted tool goes through the same custom function-tool guardrail pipeline, so a remote MCP server still needs to enforce authorization itself.
Anthropic describes client-side tool use as a contract: Claude returns a structured request, while the application executes the operation. For an application-specific tool, the execution boundary remains under the developer's control. That is where ownership can be resolved and checked. Anthropic's broader agent-security guidance also recommends limiting tools, permissions, data, and execution environments rather than treating prompt-injection detection as a complete defense.
Those mechanisms answer related but different questions:
schema validation Is the tool call well formed?
tool policy Should this tool be available or require approval?
authentication Who is making the request?
tenant ownership Does this object belong to that caller's tenant?
action authorization May the caller perform this operation?
scoped query or RLS Will the data layer enforce the boundary as well?
OAuth can establish the caller and the scopes granted to a token. It cannot discover that contract-b belongs to another customer in my application's database. That last relationship is local business data, so the application still has to enforce it.
TenantInvariant is meant to fit into that gap. It does not compete with the controls in an agent SDK or MCP host.
The relevant documentation is here:
- OpenAI Agents SDK: Guardrails
- OpenAI Agents SDK: Model Context Protocol
- Claude Platform: How tool use works
- Anthropic: Trustworthy agents in practice
What it does not do
Version 0.1.0 is a small experiment, not a complete authorization system or a security guarantee.
It does not authenticate a user. It does not decide whether the user may read, update, or delete a resource. It does not add a tenant predicate to a database query. It also cannot prevent a race between an ownership lookup and a later, unscoped fetch.
The final operation still needs to be tenant-scoped. PostgreSQL Row-Level Security can be a useful backstop if application code gets this wrong. OpenFGA, Cedar, OPA, or an existing application policy layer may handle action-level authorization.
In other words, the full path should still look more like this:
authenticated context
-> tenant ownership check
-> action authorization
-> tenant-scoped query / RLS
What I want to try next
The equality check is only a starting point. I am more interested in testing the entire route from a prompt to a database operation.
A useful test kit could inject cross-tenant resource IDs and mixed-tenant batches into real agent tool calls, then verify that every path is denied by the application's existing authorization stack. That would test the integration rather than only the small function shown above.
If I had to summarize the project in one sentence: it keeps model-generated resource IDs on the untrusted side of the authorization boundary.
I would be interested to hear where other multi-tenant SaaS or MCP implementations resolve resource ownership, and where this check lives in their request path.
The crate is available on crates.io. The source, example, and scenario tests are on GitHub. It is MIT licensed and experimental.
Top comments (2)
The principle of keeping model-generated resource IDs on the untrusted side of the authorization boundary is exactly right. The model proposes, the application decides — and the decision has to happen against server-resolved state, not model-supplied claims.
The place this fits in our stack at DataGrout is between the tenant ownership check and the action authorization layer. The crate handles the ownership comparison, but the part we kept having to add separately is: what's the sealed record proving the check ran correctly? When an agent accesses a resource, the audit trail needs to capture not just that access happened but that the ownership check passed, what policy was active, and what the authorized scope was at execution time. Otherwise the authorization stack exists but can't be proved afterward.
The batch case test is the one I'd be most interested in extending. A batch that contains resources from multiple tenants where most are valid is the failure mode most likely to ship unnoticed.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.