**Why a Databricks App agent can hand a user rows they are not allowed to see, and the one setting that decides it.**
Most writing about agent security is about what the agent can reach — which tables, which tools, which endpoints. The setting that decides what your *users* see is a different one, and it defaults to the answer you probably don't want.
## TL;DR
A Databricks App gets its own service principal the moment you create it. Unless you turn on user authorization, every question every person asks the agent executes as that one identity. Unity Catalog does exactly what it was told: it evaluates row filters and column masks against the principal that showed up, and the principal that showed up is a service account belonging to none of your groups.
Nothing is bypassed. The policy runs. It just runs against the wrong person.
**What this post is, precisely:** an analysis of the documented identity mechanics, and the experiment we designed to test them. **We have not run it yet.** Every claim below is traceable to Databricks documentation or to the shape of the mechanism itself, and where we are inferring rather than citing, we say so. The measured results are the next post.

*The policy is never bypassed — it is evaluated against a different principal. That single substitution is the whole story.*
## The insight: Unity Catalog answers a question nobody checked
A row filter is a SQL function bound to a table. Ours looks like most people's:
```sql
CREATE OR REPLACE FUNCTION agent_lab.sales.region_filter(region STRING)
RETURN is_account_group_member('agent_lab_full') OR region = 'APAC';
ALTER TABLE agent_lab.sales.orders
SET ROW FILTER agent_lab.sales.region_filter ON (region);
```
Read `is_account_group_member()` closely. It doesn't take a principal. There's no argument for *who* — the function asks about the identity executing the query, whoever that turns out to be.
In the SQL editor that identity is obvious. It's the person typing. Every mental model of row-level security is built on that, and it holds for dashboards, for notebooks, for warehouse queries, for years of BI work.
An agent breaks the assumption quietly. The person types the question, but they don't execute the SQL. Genie writes it, a warehouse runs it, and the identity attached to that execution is whatever the app handed down the chain. Under the default, that's the app's service principal.

*`is_account_group_member()` has no principal parameter. It resolves against whoever is executing, which is exactly why substituting the executor changes the answer without changing the policy.*
## What app authorization costs you
Four consequences, and only the first is the one people expect.
**The shared-identity problem.** Databricks states it plainly: with app authorization, all users who interact with the app share the same permissions, which prevents the app from enforcing fine-grained policies based on individual user identity. Every person gets identical results. Whether that's a leak or a lockout depends entirely on what you granted the service principal.
**The over-grant reflex.** This is the part that turns a lockout into a leak. Deploy an agent with a stingy service principal and it fails immediately — Genie can't read the tables, queries error, the demo dies. So you grant it `SELECT` on what it needs, and the app starts working. The permissions that made it work are the same permissions that make it answer everyone as though they were unrestricted. The failure mode is reached by fixing a different problem.
**The confidently wrong aggregate.** A row filter doesn't only hide rows. It changes the meaning of every aggregate computed over them, silently. Ask a filtered agent what share of revenue came from your region and it will compute a percentage against a denominator you can't see, and hand it over with no indication anything was withheld. That's not a leak. It's worse in one specific way: a leak is visible if you look, and this isn't.
**The audit trail names the wrong principal.** Every query in the history belongs to the service principal. You can reconstruct that the agent asked something, not that a particular person did. For a governance review, an agent under app authorization is a single user who asked ten thousand questions.
## The fix: user authorization
User authorization forwards the requesting user's downscoped token to the app through the `x-forwarded-access-token` header. MLflow's AgentServer stores it per request, and a client built from it executes as the person, not the app. Row filters and column masks then apply the way everyone already assumed they did.
Four wiring rules decide whether it works or quietly doesn't.
### Wiring rule 1: Declare the scopes, and only the scopes
Scopes are declared on the app, not in your code:
```yaml
resources:
apps:
identity_lab:
user_api_scopes:
- sql
- genie
```
The available scopes are a fixed list — `sql`, `genie`, `model-serving`, `files`, `vector-search`, `postgres`, `apps`, `ai-gateway`, plus `sql:restricted-query` for read-only SQL. If none are declared, the app gets `iam.access-control:read` and `iam.current-user:read`, which is enough to learn who the user is and nothing else.
### Wiring rule 2: Build the client per request, never at startup
```python
def _client():
if IDENTITY_MODE == "obo":
from agent_server.utils import get_user_workspace_client
return get_user_workspace_client()
return WorkspaceClient()
@stream()
async def stream_handler(request😞
wc = _client() # per-request; there is no user before one arrives
...
```
There is no user context at app startup. Construct the client in `__init__` and every request gets the service principal — and nothing anywhere will tell you, because that is a perfectly valid client.
### Wiring rule 3: Assume the fallback fired, and prove it didn't
This is the one we'd build the whole deployment around. Documented behaviour: if the forwarded token is unavailable, `get_user_workspace_client()` falls back to the app's service principal **without raising**.
Read that again in the context of a security test. You call the OBO helper, you get a working client, every query succeeds, results look sane — and you may have been running as the service principal the entire time.
```python
wc = get_user_workspace_client()
if wc.current_user.me().user_name == os.environ.get("SP_CLIENT_ID"😞
raise RuntimeError("OBO requested but resolved to service principal")
```
The cheap diagnostic is a tool that reports the identity back to you:
```python
def whoami() -> str:
"""Return the identity the agent is currently executing as."""
return _client().current_user.me().user_name
```
A real email means the token arrived. A UUID means it didn't, and you're looking at the service principal's client ID. Take the tool out before production — it hands the caller's identity to anyone who can reach the agent.

*Both branches return a working client and neither raises. Without an explicit identity assertion, a failed user-authorization path is indistinguishable from a working one.*
### Wiring rule 4: Expect to be more restricted, not less
Effective access under user authorization is the intersection of what the user holds and what the app declared. Databricks blocks anything outside the approved scopes even when the user has permission — so a user who can query a warehouse all day will be refused through an agent whose scopes omit `sql`.

*Under user authorization the agent is bounded twice. Permissions the user genuinely holds are still refused if the app never declared the matching scope.*
## What the docs don't tell you
The documented behaviours above are findable. These are the ones that will cost you an afternoon.
**Grants don't cascade, and validation won't warn you.** Granting the agent access to a Genie space does not grant its underlying tables, warehouses, or functions — each is a separate securable requiring its own grant. `databricks bundle validate` does not flag the missing ones, so the deployment succeeds and fails at first query instead.
**Scope changes look like they didn't work.** Edits reach internal caches within about five minutes, and browser cookies have to be cleared to force a new token to be issued. A scope change that appears to have no effect may simply need a wait and a fresh session — which is indistinguishable from a scope change that genuinely didn't apply.
**Consent is one-directional.** Users approve the app's scopes once, and cannot revoke that consent afterwards. Whatever you declare on day one is what your users live with.
**MCP services need three grants, not one.** `EXECUTE` on the service, plus `USE_CATALOG` and `USE_SCHEMA` on its parents. Unity Catalog validates the whole parent chain. A missing authorization surfaces as JSON-RPC error `-32042` with a login URL buried in `error.data.elicitations[]`.
**Some of your governance may not be eligible at all.** Row filters and column masks require Databricks Runtime 12.2 LTS or above; below that they fail closed and return nothing. Dedicated access mode needs 15.4 LTS. They cannot be applied to views, and a table carrying them cannot be indexed for AI Search — which quietly rules out the obvious pattern of putting a masked table behind a RAG tool.
## What changes
Three things, and we're describing the mechanism rather than a measurement.
**One agent, per-user answers.** The same deployment returns different rows to different people, because Unity Catalog evaluates the same policy against a different principal each time. No filtering logic in the agent, no per-user deployments.
**Policy stays in one place.** The row filter is still the only definition of who sees which rows. It governs the SQL editor, the dashboard, and the agent identically, which is the property that made Unity Catalog worth adopting.
**The audit trail names people.** Query history attributes each execution to the user who asked, which is the difference between a governance review that works and one that reports a single very curious service account.
## The honest fine print
**We have not run this.** Everything above is drawn from Databricks documentation and from the structure of the mechanism, not from a workspace. We think the analysis is sound, and we are not going to publish "the service principal path leaks" as a measured finding until we have measured it. The section below is the experiment; the numbers are the next post.
**The leak is conditional, and we've said so carefully.** Under app authorization the agent sees what the service principal was granted. If you granted it broadly, that's a leak. If you granted it narrowly, it's a lockout — your restricted user may see *less* than they're entitled to, and the natural fix is to over-grant the service principal, which is how the lockout becomes the leak. Which of those you're currently living with depends on your grants, and it is worth checking rather than assuming.
**Admin identities will flatten your test.** If you test this as a workspace or account admin, group membership checks may resolve trivially and tell you nothing. Bind the filter to a specific `current_user()` value, or test with an identity that genuinely lacks the privilege.
**One user is not enough to prove the interesting half.** Comparing app authorization against user authorization properly needs two real identities. A service principal won't substitute — it doesn't traverse the on-behalf-of path at all, which is the mechanism under test.
**User authorization is Public Preview.** A workspace admin has to enable it. Behaviour in preview can change, and anything here should be re-verified against the docs at the time you read this.
## The test we're running next
Ten cases, each isolating one link in the chain. Every one runs twice — once as an unrestricted identity, once as a restricted one.
| # | What it isolates | Mode | Expected if the chain holds |
|---|---|---|---|
| 1 | Policy works at all, outside any agent | — | Restricted rows only, PII redacted |
| 2 | Row filter under app authorization | SP | Suspected leak: all regions |
| 3 | Column mask under app authorization | SP | Suspected leak: unmasked contact details |
| 4 | Row filter under user authorization | OBO | Matches test 1 |
| 5 | Column mask under user authorization | OBO | Matches test 1 |
| 6 | Which identity actually arrived | both | Email under OBO, UUID under SP |
| 7 | The silent fallback | OBO | Guard raises; without it, answers as SP |
| 8 | Scope starvation | OBO | Fails closed despite user holding the permission |
| 9 | Aggregate over filtered rows | OBO | A confidently wrong percentage, not a leak |
| 10 | Direct SQL through the agent | OBO | Filter still applies; no escape via the UDF path |

*Each test isolates one link. Tests 2 and 3 carry the headline; test 9 is the one we expect to be most surprising, because it produces a wrong answer rather than a forbidden one.*
Test 9 is the reason this stopped being a straightforward security post while we were designing it. A filtered aggregate isn't a policy failure — the policy worked perfectly. It's a correctness failure produced by a security feature behaving correctly, and there's no error, no warning, and no marker in the response to tell anyone it happened.
## Takeaway
The mistake isn't misconfiguring Unity Catalog. Row filters and column masks do what they say. The mistake is assuming that a policy written about people keeps applying when the thing executing the query stops being a person.
If you have an agent in front of governed data, the question worth asking this week isn't whether your filters work. It's `whoami` — go and find out which identity your agent is actually using, because the default answer is the app, and the app is in none of your groups.
We'll publish the numbers when we have them. If your results differ from what we've predicted here, we'd rather hear it than be right.
---
*Written against Databricks documentation as of August 2026. Databricks CLI v1.14.1. User authorization is Public Preview and behaviour may change.*