embedded analytics
The filter you can see is not the one that matters

The requirement is ordinary. One set of dashboards, built once, shown inside a multi-tenant product, and each customer sees their own numbers. Every embedded BI tool sells exactly this and every one of them can demonstrate it in five minutes.
The five-minute demo and the thing that holds in production are not the same mechanism, and the gap between them is where tenancy bugs live. It is worth being precise about which is which.
Two filters
The one you can see. The dashboard has an Organization filter, and the
embedding application sets it in the iframe URL and then hides the control:
export const setDefaultSearchParams = (
searchParams: URLSearchParams,
theme: string,
accountSlug?: string,
) => {
searchParams.set('allow_login_screen', 'false')
searchParams.set('theme', theme)
if (accountSlug && !searchParams.has('Organization')) {
searchParams.set('Organization', accountSlug)
}
if (!searchParams.has('hide_filter')) {
searchParams.set('hide_filter', 'Organization')
}
}
That is presentation. It puts the dashboard in the right state, it stops a confusing empty filter chip appearing above every tile, and it is worth nothing as a boundary. It is a query-string parameter in a URL, in a browser, belonging to the person we are trying to constrain.
The one that matters. The embed user carries a user attribute holding the organisation identifiers it may see, and the model's access filter applies it server-side while the SQL is being built. The browser never participates. There is no request the user can craft that removes it, because it is not in a request.
Both exist. Only one of them is the control. The reason to write this down is that in any demo, and in most incident post-mortems, people point at the first one.
The embed user is not a user
Embed users are created on demand from the product's own identifier, not provisioned in advance:
public async createEmbedUser(externUserId: string): Promise<IUser> {
return this.adminSDK.ok(this.adminSDK.create_embed_user({ external_user_id: externUserId }))
}
Their groups and attributes are then recomputed every time a session is acquired. The important word in that sentence is recomputed — not assigned:
const actualUserGroups = await this.getUserGroupsForUser(user)
const missingUserGroups = filter(actualUserGroups, (ag) => !includes(lookerUser.group_ids, ag))
const groupsToRemove = filter(
lookerUser.group_ids,
(groupId) => !includes([LOOKER_DEFAULT_USER_GROUP, ...actualUserGroups], groupId),
)
Adding the missing groups is the obvious half. Removing the extra ones is the half that gets skipped, and skipping it means a user demoted from editor to viewer keeps the editor group forever, because nothing ever went back and took it away. Permission code that only ever adds is a ratchet. The default group has to be excluded by name, because the tool puts everyone in it and removing people from it is not a thing you are allowed to do.
Your tenancy boundary is a LIKE pattern
Two attributes carry the scope. The first is a comma-joined list of organisation identifiers. The second is finer-grained — organisation and location together — and it is where the mechanism shows what it really is:
forEach(accounts, (a) => {
if (some(a.locations)) {
forEach(a.locations, (l) => orgLocationPairs.push(`${a.id}_${l}`))
} else {
orgLocationPairs.push(`${a.id}^_%`)
}
})
That ^_% is not decoration. The pair is matched with LIKE, _ matches any
single character, and the separator between the two halves is an underscore.
So a user scoped to a whole organisation with no location restriction needs
"this organisation, then a literal underscore, then anything" — which is an
escaped _ followed by %, with ^ as the escape character.
Get that wrong by one character and 123_% matches organisation 1234 as well
as 123. The escaping is not a formatting detail; it is the boundary. Any
tenancy scheme built on string patterns has a version of this line in it
somewhere, and it deserves a test with two organisations whose identifiers share
a prefix. Ours has one now.
The blunter instrument is next to it. An internal user — represented by an empty
accounts array — is mapped to %:
const accounts = some(user.accounts) ? map(user.accounts, 'id') : ['%']
Match everything. That is a legitimate requirement; support staff need to see a customer's dashboard to answer a question about it. What I do not like is that the privilege is expressed as an array being empty. An empty collection is something a mapping bug produces by accident, and here it grants access to every tenant in the instance. It should have been an explicit role, checked explicitly, and the fact that it works is not an argument.
Cache the result, key it by the input
Reconciling groups and attributes on every session acquire is several API calls against the BI instance, on a path a user is waiting on. The cache avoids them, and the shape is worth stealing:
private async getActualCachedLookerUser(userData: User): Promise<IUser | undefined> {
const userHash = objectHash(userData)
const lookerUser = await this.cacheManager.get(this.getUserCacheKey(userData))
if (lookerUser && lookerUser.hash === userHash) {
return lookerUser
}
}
The cached record carries a hash of the input that produced it, and a hit is only honoured if the hash still matches. Change a permission anywhere and the hash changes, the entry is ignored, and the reconciliation runs. There is no invalidation call to forget, no TTL to tune, and no window in which a revoked permission is still being served from cache — the invalidation is structural.
The alternative, which is what was there first, is a short TTL and the hope that nobody notices the gap. The gap is the whole risk.
There is a related bug in the same area that is a good reminder of how these things bleed. The attributes are stored as comma-joined strings, and the check for "has this changed" compared the strings. The same set in a different order is a different string, so the service rewrote both attributes on a large share of requests — no incorrect behaviour, just a steady stream of writes to the BI instance's API for no reason. Sorting both sides before comparing removed it:
if (!isEqual(sortBy(lookerOrgIds), sortBy(accounts))) {
await this.lookerClient.setUserAttribute(lookerUser, ORG_ATTR_ID, accounts.join(','))
}
Any time you serialise a set to compare it, you have signed up for ordering.
Cookieless, and why it is not about Chrome
The embed session is cookieless. It is tempting to file that under third-party cookie deprecation, and after Google's announcement last month that Chrome will keep them and offer a choice instead, that filing would age badly. The real reason is older and simpler: Safari and Firefox have blocked or partitioned third-party cookies for years. An iframe served from another origin has not been able to rely on a cookie for a long time, whatever Chrome decides next.
So the session is four tokens with four separate lifetimes, and where each one lives is the design:
const {
authenticationToken, // used once, to enter
navigationToken, // in the iframe URL
apiToken, // posted into the iframe
sessionReferenceToken, // never leaves the server
} = await acquire()
The authentication token is spent immediately on the login URL. The navigation
token rides in the iframe address. The API token is handed to the iframe over
postMessage. The session reference token — the one that can mint new tokens —
is stored server-side against the user and never reaches the browser at all.
Refresh is a message, not a reload:
postMessageToIframe({
type: 'session:tokens',
navigation_token: navigationToken,
navigation_token_ttl: navigationTokenTtl,
api_token: apiToken,
api_token_ttl: apiTokenTtl,
session_reference_token_ttl: sessionReferenceTokenTtl,
})
A user reading a dashboard for an hour never sees anything happen. The tokens turn over underneath them, the server re-checks who they are on each acquire, and the one credential capable of extending the session was never in reach of the page. That last property is what makes the whole arrangement worth the extra moving parts — a token that can renew itself is a session, and sessions belong on the server.
Seats leak, so something has to sweep
Create an embed user per product user and the count only ever goes up. People leave, trials end, test accounts pile up, and the instance keeps every one of them. A job walks the non-disabled embed users and turns off the ones that are not being used:
const inActiveUsers = users.filter((user) => isEmpty(user.sessions))
for (const user of inActiveUsersInCurrentEnv) {
const plans = await this.lookerClient.getAllScheduledPlans({ user_id: user.id })
if (isEmpty(plans)) {
await this.disableUser(user)
}
}
The scheduled-plan check is the part that is easy to leave out and expensive to leave out. A user with no sessions looks abandoned, but if they own a scheduled delivery they are still doing work every week that somebody downstream depends on. Disabling them stops the report and nothing raises a hand; the first signal is a customer asking where their Monday email went.
The sweep itself is not correct in one pass, and I would rather say so than imply otherwise. It pages with limit and offset over a filtered list — users that are not disabled — while disabling users, which removes rows from under the cursor and causes the next page to skip records. It converges across repeated runs rather than being right on any single one. That is an acceptable property for a housekeeping job and an unacceptable one for anything that matters, and the distinction is worth keeping clear in your head when you reach for offset paging.
The part to remember
Two mechanisms, near-identical on screen: a parameter the browser sets, and an attribute the server attaches to a user before it writes any SQL. Everything above — the escaping, the reconciliation, the hash-keyed cache, the token that stays behind — exists to keep the second one true on every request, including the ones nobody demoed.
The visible filter is for the person reading the dashboard. It should be there. It should also never be the answer to "how do we know they cannot see another customer's revenue".
Written by
Deyan Peev
Founding Engineer · Sofia, Bulgaria


