{"slug": "who-decides-the-tenant-a-small-rust-guard-for-ai-tool-calls", "title": "Who Decides the Tenant? A Small Rust Guard for AI Tool Calls", "summary": "A developer has released TenantInvariant, an experimental Rust crate that enforces tenant isolation as an executable invariant before an AI agent's tool call runs. The library ignores tenant IDs produced by the model, instead comparing the authenticated actor's tenant against server-resolved resource ownership and failing closed for cross-tenant or unknown owners. The author argues that prompt engineering cannot serve as an authorization boundary when a model supplies resource IDs.", "body_md": "I have been thinking about a fairly ordinary failure mode in AI-enabled SaaS products.\n\nA support agent is helping a user from `tenant-a`. The user asks about a contract, and the model produces a perfectly valid tool call:\n\n```\nget_contract(\"contract-b\")\n```\n\nThere 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`.\n\nA better prompt might make this happen less often. It cannot turn the prompt into an authorization boundary.\n\nI made TenantInvariant, a small experimental Rust crate, to explore where that boundary should live.\n\n**What if an AI agent chooses a resource ID that belongs to another customer?**\n\n`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.\n\nIt does not trust tenant IDs produced by the model.\n\n``` php\nagent proposes resource ID\n  -> server resolves the resource owner from a trusted source\n  -> TenantInvariant compares actor and owner\n  -> existing authorization and tenant-scoped data operation\n```\n\nYou need Git and the stable Rust toolchain. If Rust is not installed, use [`rustup`, the installer recommended by the Rust project](https://www.rust-lang.org/tools/install):\n\n```\n# macOS, Linux, or WSL\ncurl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh\n```\n\nOn Windows, download and run `rustup-init.exe` from the same official installation page. Restart the terminal…\n\nTool calling adds a new decision-maker to an otherwise familiar request path:\n\n``` php\nuser prompt\n  -> model chooses a tool\n  -> model supplies resource IDs\n  -> application executes the tool\n```\n\nIt is tempting to put both `tenant_id` and `contract_id` in the tool schema. That gives the model a neat, self-contained payload:\n\n```\n{\n  \"tenant_id\": \"tenant-a\",\n  \"contract_id\": \"contract-b\"\n}\n```\n\nIt 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.\n\nThe rule I settled on is simple: the model can propose a resource ID, but it cannot tell the application who owns that resource.\n\nThe 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.\n\n```\nAI proposes a resource ID\n        |\n        v\nserver resolves the real owner\n        |\n        v\nauthenticated tenant == resource owner?\n        | yes                         | no / unknown\n        v                             v\nnormal authorization continues      deny\n```\n\nI chose to deny an unknown owner as well. If the lookup fails, there is not enough information to authorize the operation.\n\nThe actual comparison could be an `if` statement. I did not want to hide that fact behind a large abstraction.\n\nThe 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.\n\n```\nuse tenant_invariant::{\n    check_tenant, Actor, Decision, ResourceOwner, TenantId,\n};\n\nfn main() {\n    let actor = Actor {\n        // This value comes from the authenticated application context.\n        tenant: TenantId::new(\"tenant-a\")\n            .expect(\"tenant ID must not be empty\"),\n    };\n\n    // The server looked this up using the ID proposed by the model.\n    let owner = ResourceOwner::Tenant(\n        TenantId::new(\"tenant-b\")\n            .expect(\"tenant ID must not be empty\"),\n    );\n\n    match check_tenant(&actor, &owner) {\n        Decision::Allow => {\n            // Continue with normal authorization and a scoped query.\n        }\n        Decision::Deny(reason) => {\n            eprintln!(\"blocked: {reason:?}\");\n        }\n    }\n}\n```\n\nYou can install it from crates.io:\n\n```\ncargo add tenant-invariant\n```\n\nThere is also a runnable example in the repository:\n\n``` bash\n$ cargo run --example contract_lookup\nblocked: CrossTenant\n```\n\nRust 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.\n\nThe 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.\n\nI 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.\n\nThe 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`.\n\n```\nproptest! {\n    #[test]\n    fn different_tenants_are_always_denied(\n        a in \"[a-z0-9]{1,12}\",\n        b in \"[a-z0-9]{1,12}\",\n    ) {\n        prop_assume!(a != b);\n\n        let actor = Actor { tenant: tenant(&a) };\n        let owner = ResourceOwner::Tenant(tenant(&b));\n\n        prop_assert_eq!(\n            check_tenant(&actor, &owner),\n            Decision::Deny(DenyReason::CrossTenant),\n        );\n    }\n}\n```\n\nFor this kind of library, the property is almost the product.\n\nBoth OpenAI and Anthropic already have controls around agent tools, so I wanted to understand whether this crate was duplicating them.\n\nOpenAI'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.\n\nAnthropic 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.\n\nThose mechanisms answer related but different questions:\n\n```\nschema validation       Is the tool call well formed?\ntool policy             Should this tool be available or require approval?\nauthentication          Who is making the request?\ntenant ownership        Does this object belong to that caller's tenant?\naction authorization    May the caller perform this operation?\nscoped query or RLS     Will the data layer enforce the boundary as well?\n```\n\nOAuth 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.\n\nTenantInvariant is meant to fit into that gap. It does not compete with the controls in an agent SDK or MCP host.\n\nThe relevant documentation is here:\n\nVersion 0.1.0 is a small experiment, not a complete authorization system or a security guarantee.\n\nIt 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.\n\nThe 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.\n\nIn other words, the full path should still look more like this:\n\n``` php\nauthenticated context\n  -> tenant ownership check\n  -> action authorization\n  -> tenant-scoped query / RLS\n```\n\nThe equality check is only a starting point. I am more interested in testing the entire route from a prompt to a database operation.\n\nA 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.\n\nIf I had to summarize the project in one sentence: it keeps model-generated resource IDs on the untrusted side of the authorization boundary.\n\nI 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.\n\nThe crate is available on [crates.io](https://crates.io/crates/tenant-invariant). The source, example, and scenario tests are on [GitHub](https://github.com/subaru-hello/tenant-invariant). It is MIT licensed and experimental.", "url": "https://wpnews.pro/news/who-decides-the-tenant-a-small-rust-guard-for-ai-tool-calls", "canonical_source": "https://dev.to/subaruhello/who-decides-the-tenant-a-small-rust-guard-for-ai-tool-calls-4pc4", "published_at": "2026-09-21 01:28:40+00:00", "updated_at": "2026-09-21 01:52:44.820320+00:00", "lang": "en", "topics": ["ai-agents", "ai-safety", "developer-tools", "ai-tools"], "entities": ["TenantInvariant", "Rust"], "alternates": {"html": "https://wpnews.pro/news/who-decides-the-tenant-a-small-rust-guard-for-ai-tool-calls", "markdown": "https://wpnews.pro/news/who-decides-the-tenant-a-small-rust-guard-for-ai-tool-calls.md", "text": "https://wpnews.pro/news/who-decides-the-tenant-a-small-rust-guard-for-ai-tool-calls.txt", "jsonld": "https://wpnews.pro/news/who-decides-the-tenant-a-small-rust-guard-for-ai-tool-calls.jsonld"}}