# Embedded analytics on Databricks: tenant isolation in one JOIN

> Row-level security for embedded AI/BI dashboards when viewers have no Databricks identity. One scope table, one JOIN on the embed token, fail-closed.

Published: 2026-11-02
Author: Andrey Taran
Tags: Databricks, Field Notes, Knowledge Share
Canonical: https://www.techfabric.com/blog/embedded-analytics-on-databricks-tenant-isolation-in-one-join

---

A client application we at TechFabric work on shows each of its clients their own reports inside its portal, and in [part 3](/blog/embedded-analytics-on-databricks-one-dashboard-not-five) we folded those five reports into one Databricks dashboard. With external viewers the next requirement arrives before anyone writes a ticket for it. One client must never see another client's rows, or the product can't ship.

The viewers are the application's clients, and they have no Databricks identity at all. They open the boards inside the portal, and one service principal renders every board for everyone. Unity Catalog row filters and column masks decide by the identity that runs the query, and here that identity is the same for every client, so the platform's own isolation had nothing to work with.

We put row-level security for embedded dashboards into the dashboard SQL itself. Onboarding a new client became one row in a table with no change on the Databricks side, and a broken token shows an empty board instead of someone else's data. It's one more step in moving the application's client reporting onto Databricks, and the first question was why the catalog's own filters didn't fit.

## Why the catalog filters didn't fit

A row filter is bound to whoever runs the query. In the embedded setup that's always the service principal, so a filter would've seen one identity for every client. We could've created a service principal per tenant, but then every new client would've meant a new identity, new grants and a new deployment. We wanted onboarding to be one row in a table, with nothing to change on the Databricks side.

## One scope table and a JOIN on the token

It works in four parts.

- The application owns the scope. When a tenant's scope changes, it writes a table in the reporting database with one row per tenant and allowed id, and nothing but ids.
- The backend mints a short-lived embed token per board. The tenant id comes from the signed-in user's claims and never from the request, so a viewer can't widen their own scope.
- Databricks exposes the token's external value inside dashboard SQL, and every dataset joins the scope table on it. A tenant is scoped either by client or by partner, and the join picks the matching column on the fact table.
- Outside an embed the value is empty, the join returns no rows, and the board shows nothing. We'd rather show an empty board than someone else's data.

```mermaid
%% caption: The tenant id comes from the signed-in user's claims, the backend puts it into a short-lived embed token, Databricks exposes it inside dashboard SQL, and every dataset joins the scope table on it, so an empty value returns no rows.
flowchart TD
  A["Tenant id from the signed-in user's claims"] --> B["Backend mints a short-lived embed token"]
  B --> C["Databricks exposes the external value inside dashboard SQL"]
  C --> D["Every dataset joins the scope table on it"]
  D --> E["The matching column on the fact table"]
  C --> F["Empty value, no rows, the board shows nothing"]
  class D accent
```

## Case, EXISTS and the scope version

Three small things, and each of them returned zero rows with no error at all.

The scope table stored lowercase GUIDs, the fact table stored uppercase ones, and Spark string comparison is case-sensitive. We uppercase the small side, the scope table, and leave the fact column bare so the filter stays cheap and the source can still use its indexes.

Our first version used a correlated EXISTS, and it ran once per row over federation. We replaced it with a plain JOIN.

The key inside the token carries the tenant id plus a scope version, a hash of the sorted scope set. When one tenant's scope changes, only that tenant's 24-hour dashboard cache goes cold. We tested it on live data, and the hash was stable for one tenant, distinct across tenants, different once the set narrowed, and identical for the same set in reversed order.

## The separator that blanked every board

The first separator between the two halves of the key was a vertical bar. The embed claim reserves the colon and the vertical bar for its own fields, so Databricks lost the trailing dashboard id and answered 400 "Dashboard ID is missing in token claim" to every board load in test. The portal clipped the error banner, so the board showed a blank white rectangle. At design time we'd checked the key's length and missed its character set. The hyphen and the `_` character were out as well, because both occur inside a GUID. We moved to the tilde.

Meanwhile every other check passed. Deploy, SQL, grants, token minting, even the portal's load event fired, because the iframe did load. None of them read the dashboard's own response. It happened in the test environment, and production was consistent at the time.

## The two gates that guard it now

The first gate runs at build time. A validation step fails on a reserved separator or on a bare read of the key without parsing it. The second runs after each deploy. A smoke test mints a real token with a synthetic scope and opens every board through the same embed endpoint the portal uses. When we probed it with the vertical bar on purpose, it failed 7 boards out of 7 with the exact 400.

## Where it stands

Onboarding a client is one row in the scope table and no change in Databricks. The isolation lives in one JOIN that every dataset carries, and it fails closed. The honest next step is a scoped view per fact table, so the JOIN can't be forgotten in a new dataset and CI can refuse any dataset that reads around it. The part after this one is about the client's request for real time, what it costs on a live database, and the compromise we settled on.

## Frequently asked questions

### Why not one service principal per tenant with Unity Catalog row filters?

Because every new client would need a new identity, new grants and a deploy, and the scope would live in Databricks instead of in the application. The scope table gives onboarding by one row and keeps one source of truth where the tenant data already is.

### What does a user see when the token is missing or broken?

An empty board. The external value is empty, the JOIN returns no rows, and no dataset has anything to show. Showing too much isn't a possible failure mode of this design, and showing nothing is the one we chose.

### Why does the key carry a scope version when the tenant id is enough to filter?

For the cache. Dashboard results are cached by key, and without a version a tenant's scope change wouldn't reach the screen until the day was over. The version is a hash of the sorted set, so the same set always yields the same key.

### Why does the smoke test go through the embed endpoint instead of running the SQL?

Because the embed endpoint is where it broke. The SQL was right, the token was minted, the portal logged the iframe load, and only the dashboard's own response carried the 400. A check has to read what the user actually sees.
