DEV Community

subaru
subaru

Posted on AI-assisted

Who Decides the Tenant? A Small Rust Guard for AI Tool Calls

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")
Enter fullscreen mode Exit fullscreen mode

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.

GitHub logo subaru-hello / tenant-invariant

Executable tenant-isolation invariants for AI agent tool calls, written in Rust.

TenantInvariant

CI crates.io downloads License: MIT

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:?}");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

You can install it from crates.io:

cargo add tenant-invariant
Enter fullscreen mode Exit fullscreen mode

There is also a runnable example in the repository:

$ cargo run --example contract_lookup
blocked: CrossTenant
Enter fullscreen mode Exit fullscreen mode

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),
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
murali_gour_13cd7a6a6db2c profile image
Murali Gour

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.