Founder pricing: Pro at $9.99/mo forever for the first 50 members. 18 of 50 left Claim yours →
All posts

Multi-tenant SaaS and AI coding agents: a global scope is not as global as it sounds

The SanuDesk team 6 min read
multi-tenant security ai agents

Ask an agent to add a "usage this month" export for support, and it does the obvious thing: pull every row from the usage table, group by account, write a CSV. It works. You open the file, check the numbers for your own test account, and they match. What you don't check is the row two down: a different company's usage, sitting in the same export, because the query never asked which account a row belonged to. Nothing forced it to ask.

Nobody wrote a bug. The agent built exactly the report it was asked for, against a table that happens to hold every customer's data in one place.

Why a tenant-scoping miss is invisible to an agent

A working query and a correctly scoped query produce the same result in almost every session: one account, one seeded dataset, one developer looking at their own data. The row count is right. The test, if there is one, asserts on the shape of the response, not on whose data is in it. "Correct" and "correctly scoped" only diverge once two tenants' rows land in the same result set, and a fresh Claude Code, Codex or Gemini session building a feature against a fixture with one seeded company has no way to notice that divergence exists.

This is a narrower, sharper version of a miss this blog has already named: authorization that checks who you are and stops there. The tenant version is easier to write by accident: the query usually is scoped correctly, to a model, just not to the account that model belongs to. It's the same blind spot a migration backfill runs into when it updates every row in one pass: the mechanism knows which rows match the query, not which tenant each one belongs to.

The scope that isn't actually global

Laravel's own name for the fix invites the mistake. A global scope attaches a constraint to a model with one attribute, #[ScopedBy], or one call in booted(), and every query for that model carries it from then on. It reads like protection that, once added, covers the app. It covers one model.

Three gaps sit inside that single word "global":

  • It's per model, not per app. Add TenantScope to Invoice and Invoice::all() is safe. The MonthlyReport model an agent adds next sprint starts with no scope at all until someone remembers to attach one, and remembering isn't a step anywhere in the brief "add a monthly report."
  • It doesn't reach DB::table(). A scope lives on the Eloquent builder for its model. The moment a query drops to the plain query builder, for an awkward join or a hand-written export, the scope isn't skipped. It was never in the request.
  • It's one call away from gone. withoutGlobalScopes() exists for legitimate admin tooling, and it's also what an agent reaches for when a scope is "in the way" of a query it can't get working, with no signal it just deleted the tenant boundary along with the actual problem.

None of this is a bug in Laravel. A global scope does precisely what its documentation says. The gap sits between what the name promises and what one attribute on one model actually reaches.

The worker that remembers the wrong tenant

Queued jobs open a second, quieter gap. Laravel's own docs are direct about it: queue workers are long-lived processes, and "any static state created or modified by your application will not be automatically reset between jobs." A singleton container binding, the usual first instinct for "current tenant," is exactly that kind of static state. It resolves once and answers the same way for every job the worker picks up afterward, not just the one it was bound for.

Laravel ships the actual fix: a scoped binding instead of a singleton, flushed at the start of every new request or job. But scoped is the one you have to choose on purpose. singleton is what every tutorial reaches for first, and in a web request or a worker restarted between local test runs, the two behave identically. They diverge only under the same production load that makes background jobs run more than once: a long-lived worker, processing job after job, tenant after tenant, with no restart in between.

Where tenant isolation goes wrong, and what it really costs

These bugs are quiet by construction. Nothing throws. The wrong row renders inside a page that otherwise looks correct, to a user with no way to know it isn't theirs.

A real one already happened at MCP-server scale. Asana's MCP integration shipped May 1, 2025. A logic flaw let one organization's project data reach another organization's AI assistant, undiscovered for over a month before Asana pulled the feature offline for two weeks to fix it. Roughly a thousand customers were told to review what their assistants may have ingested from accounts that weren't theirs. Nobody had to attack anything. A boundary that should have applied per organization didn't, in a feature shipped fast and working exactly as tested.

The forensic question is worse than the fix. The code change is usually one line: add the missing scope, or restore scoped over singleton. Figuring out how long the gap existed, and which accounts saw whose data, is the part that takes weeks, and the part regulators and enterprise customers actually ask about.

It's a contract problem before it's a compliance problem. For a B2B SaaS product, a cross-tenant leak reads as proof the isolation a prospective customer is paying for doesn't hold, closing deals a clean pen-test report was supposed to open.

Read the scope, not just the diff

SanuDesk is a desktop app built around exactly that gap between code that passed review and code actually checked for whose data it touches. Your Claude Code, Codex and Gemini sessions tile into one grid, so a new DB::table() call or a withoutGlobalScopes() sitting in the same diff as a new feature is something a reviewer reads next to the change, not something found later in an audit. Work arrives through a Kanban board: "add the usage export" is a card, and "does every query here still carry the tenant scope" is a question the review lane can ask before the card moves to done. Because SanuDesk is bring-your-own-model, that review can run on a cheaper model than the one that wrote the feature. A recurring sweep, grepping for raw queries and withoutGlobalScopes calls against tenant-owned tables, becomes a Loop, journaled per run instead of a check everyone means to schedule. The grid and board are in the free plan (see pricing).

Run the query your global scope is supposed to block

Pick your most sensitive tenant-scoped model and run two counts in php artisan tinker: YourModel::count() against YourModel::withoutGlobalScopes()->count(). If they match, either your seed data only has one tenant, which tells you nothing, or nothing is actually scoping that model yet, which tells you everything. Then grep the codebase for that model's table name next to DB::table(. Every hit is a query your scope never protected.

Download SanuDesk free to give every query a reviewer before it ships, or see how the grid, the board and Loops fit together on the features page.

Run your own fleet of agents

SanuDesk is a free desktop app that tiles your Claude Code and Codex sessions and gives every project a Kanban board your agents work from.