September 2, 2026

Your permissions hold right up until the model calls a tool

Almost everyone gets retrieval scoping right. The agent cannot read the wrong customer's documents. Then it calls a tool, and the tool goes and fetches them, because the tool runs as your service account and nobody re-checked.

Thin amber linework on charcoal: a token shape whose listed permissions form a wall, with a tool call stopping against it.

This is the bug I have watched more teams ship than any other, including teams who are good at this. It survives review because the review looks at the retrieval layer, and the retrieval layer is correct. The hole is one layer down and one step later.

The shape of it

You have multi-tenant data. You do the right thing: every vector search is filtered by tenant, every document row carries an owner, the filter is in one place and it is tested. A user asks a question, retrieval returns only their chunks, the model answers from those chunks. Good.

Then someone asks for the agent to actually do something — look up an order, check a subscription, run a report. So you give it a tool. The tool is a function in your backend. It takes an order id.

Which order ids can it take?

In most first implementations: all of them. The tool holds a database handle. The database handle is not scoped to anything, because it is your own backend calling your own database, and the tenant filter lives up in the retrieval code where it has always lived. The model produces an argument. The argument is an id. The function runs.

Now the boundary is a string the model chose.

You do not need prompt injection for this to go wrong — a confused model guessing at an id from an earlier turn will do. But injection makes it reliable. A document uploaded by one customer contains a sentence addressed to the model. The model has been told, correctly, that the document is data. It has also been given a tool that fetches an order by id, and no part of the stack refuses when it does.

What a capability actually has to be

The version that works is not a rule the model follows. It is a check the model cannot reach.

In Oberik your backend mints a token per end-user, and the token carries what that user may do:

ts
const { access_token } = await oberik.tokens.mint({
  subject: `${org.id}:${team.id}:${user.id}`,
  scope: `${org.id}:${team.id}`,
  capabilities: ["chat", "documents:read"],
  expiresIn: 900,
});

Two claims doing two different jobs. capabilities is what it may do; scope is a path prefix bounding what it may see. Neither can be widened by anything downstream — not by the request, not by the model, not by a tool.

And the part that matters for the bug above: anything not granted is refused, including a tool the model tries to call anyway. Not "the tool is not described in the prompt so it probably won't be called". The capability is checked in the data plane, on every call, at the moment the tool would run. A token without computer does not have sandbox tools that are politely hidden; it has sandbox tools that do not exist for it, and a call to one is refused before anything executes.

That is the whole design. Prompt injection cannot reach a capability you did not grant, because the refusal is not a decision the model participates in.

You can narrow further per request, which is worth knowing about:

ts
await ai.chat.send({ message, allowed_tools: ["rag_search"] });

One turn, one tool. Useful when a capability is granted for the session but you want this particular turn to be read-only.

The mistake that is easiest to make

Three role names mean something to the data plane: admin, owner, and service. A token carrying any of them reads every end-user's sessions in the project, every document and memory regardless of owner, and can call the audit and erasure endpoints.

Your application also has roles. One of them is probably called admin.

If your backend maps its own user roles onto the token's roles field — which is the obvious thing to do, because that field exists for ACL labels and your roles are labels — then one customer's admin gets every customer's conversations. Not their documents only. Their chat history.

We could have made this impossible by naming those three claims something nobody would collide with. We did not, because they need to be nameable, and a system where the privileged role is spelled __oberik_internal_admin_v2 is a system where somebody writes a helper to translate their roles into it and reintroduces the whole problem with an extra step.

What we did instead: the mint response carries a warnings array whenever a privileged role was granted, so it is a thing you can assert on in a test rather than a thing you find out about.

jsonc
{ "capabilities": ["chat", "documents:read"],
  "warnings": ["'tasks' is not a capability — did you mean 'tasks:read' or 'tasks:write'? …"] }

Read that array on every mint. It also catches the other silent failure: a capability you asked for and did not get, because the project's ceiling trimmed it. That one is invisible until something else refuses — and worse, it often surfaces as the agent explaining in prose that it cannot help, which no test can assert on. A mistyped capability name mints happily and produces a 403 forty seconds later from whichever call needed it.

Two mistakes, two different fixes: one is a spelling, one is a dashboard toggle. The warning tells you which.

Scopes, briefly

scope is a path with : as the separator, and a token sees its own subtree.

subjectscopesees
acme:fin:anaacme:fin:anaAna's own documents and sessions
acme:fin:leadacme:fineverything in finance
acme:adminacmethe whole customer

Use a project per customer for the hard boundary, and scopes for the hierarchy inside it. The reason to keep those two mechanisms separate rather than making scope do both: a project is its own tenant with its own vectors, objects, credentials and spend, and "isolated" should mean a different row space rather than a different string prefix on the same rows. A prefix bug is one WHERE clause away from being everyone's problem.

One asymmetry worth knowing, because it surprises people: memory follows subject alone, never scope. A team lead with scope: "acme:fin" reads every document in finance and exactly one person's memories — their own. A document belongs to a team. A memory is something a person told the assistant, and "my reports can see the team's files" is a different decision from "my assistant may repeat what my reports said in confidence".

The test to write

Mint a token for user A. Ask the agent, in the plainest possible language, to fetch something belonging to user B — an order id, a document name, whatever your product's primary object is. Then do it again with the id embedded in a document A uploaded.

If both refuse at the tool boundary rather than at the retrieval boundary, you are fine. If the second one succeeds, the model is not the problem and no amount of prompting will fix it.

Free while we launch, your own provider keys. oberik.com