Solution Architecture  /  Analytics  /  Powerhouse · EXO

The relay that
owns nothing.

EXO puts governed Power BI data inside a React app we built ourselves — but instead of a service principal minting embed tokens, it forwards each person's own sign-in token through a thin relay that holds no data secret at all. Power BI decides which rows they see. We decide how the charts look.

EXO · REACT Next.js · Vercel Apache ECharts RELAY · BFF owns no data secret verify → forward keeps nothing POWER BI semantic model · DAX RLS as THIS user BIONIC / SKYFII realtime REST · Basic cred in env the user's token THE BROWSER HOLDS ITS OWN TOKEN · POWER BI DECIDES THE ROWS · THE RELAY KEEPS NOTHING

Our numbers were already clean and governed, sitting behind a Power BI semantic model. What they didn't have was a home our staff actually wanted to open. The stock Power BI portal is fine — it just isn't ours, and "go log into that other tool" is where good analytics quietly goes to be ignored.

So EXO grew out of one ambition: surface that governed data inside a custom React app that feels like the rest of what we build — our layout, our typography, Apache ECharts instead of Power BI's chrome. The charts were never the hard part. The hard part was the thin line between data a browser must never touch directly and an experience we wanted to control completely. The received wisdom for crossing that line is heavier than the job needs. This is the light way we found.


01 · THE PROBLEMGoverned data, our own front end, and a browser that can't be trusted

The data lives behind a Power BI semantic model — the governed layer with its measures, relationships and row-level security defined once. EXO doesn't re-model anything; it reads that model and puts it in front of people.

The moment you decide the experience will be a React app rather than the stock Power BI service, three questions land on your desk at once. If our app draws the dashboards, how does it get numbers out of a Power BI semantic model at all? How does each person see only the rows their role permits? And how does any of that happen without a Power BI or Fabric secret ending up somewhere a browser could read it?

And a fourth, practical one that ambushes everyone: Power BI's REST API has no CORS. A browser literally cannot call it. So even before security, there is a plumbing wall between our React app and the data.

02 · THE WRONG TURNThe two heavy defaults

Two answers come up again and again, both heavier than the problem.

The first is the documented app-owns-data embed: a service principal owns the reports, the backend calls GenerateToken to mint a short-lived embed token with an "effective identity" stamped on it, and the browser embeds a Power BI report. It works — but it drags a service-principal secret, Key Vault, and a whole token-minting machinery into your build, and, crucially, you're still rendering Power BI's report inside an iframe. That's not our app; it's Power BI wearing our header.

The second is to abandon Power BI and rebuild every dashboard against raw data APIs — which throws away the semantic model, the measures, and the row-level security we'd already paid for, and replaces them with a charting library and a standing promise to reimplement governance by hand, forever.

Architecture decision

We set both aside. We wanted the governance of the semantic model and a UI that is genuinely our own React app with ECharts — without a service-principal secret to guard and without discarding the model. Neither default gives you both.

There was a quieter worry underneath: people assumed bridging "our edge app" and "the data behind Azure" meant a fat gateway or a secret-holding backend. It didn't. It meant one honest server-side seam that holds nothing — which is the whole point of what follows.

03 · THE KEY INSIGHTForward the user's own token; let Power BI do the security

Here's the move we'd nearly talked ourselves past. Power BI's executeQueries endpoint accepts a delegated user token — the ordinary OAuth2 access token the person already gets when they sign in. If we forward that, Power BI evaluates the query as the actual person, and the row-level security defined once in the model applies to them automatically. No service principal. No embed token. No effective identity to stamp — because the identity is the real user.

So the relay needs no data secret of its own. It only has to verify the caller's own token — signature, tenant, audience — and forward it, verbatim, to Power BI.

No service principal. No embed token. The relay hands out nothing it doesn't already have — it just proves the token is real and passes it on. Power BI, not our code, decides which rows come back.

That relay is a Backend-for-Frontend — a same-origin Next.js API route. It exists for two honest reasons: Power BI has no CORS (so the browser can't call it), and we want the token verified before it leaves for Power BI. It carries the entire design, and it is about forty lines.

Auth, precisely: corporate SSO — SAML-federated into Microsoft Entra ID — signs the person in. In the browser, MSAL (OIDC / OAuth2 with PKCE, no client secret) then acquires a delegated access token for the Power BI API. That token — the user's, scoped to them — is what rides through the relay.

// POST /api/fabric/execute-queries — the relay that owns no secret (abridged)
export async function POST(req: Request) {
  // 1. verify the caller's OWN delegated Entra token
  const token = bearer(req);
  const { payload } = await jwtVerify(token, jwks, {
    issuer: [
      `https://sts.windows.net/${TENANT}/`,               // Power BI issues v1 tokens...
      `https://login.microsoftonline.com/${TENANT}/v2.0`, // ...under an MSAL v2 authority
    ],
  });
  if (payload.tid !== TENANT || !audienceIsPowerBI(payload)) return unauthorized();

  // 2. only registered models may be queried (config-driven allow-list)
  if (!ALLOWED_MODELS.has(`${workspaceId}/${datasetId}`)) return forbidden();

  // 3. forward the user's OWN token — RLS applies to THEM
  const path = workspaceId
    ? `groups/${workspaceId}/datasets/${datasetId}`  // shared workspace
    : `datasets/${datasetId}`;                        // "" = My workspace
  const r = await fetch(`https://api.powerbi.com/v1.0/myorg/${path}/executeQueries`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}` },   // verbatim — no secret of ours
    body: JSON.stringify({ queries: [{ query: dax }] }),
  });
  return new Response(await r.text(), { status: r.status });
}

The relay verifies and forwards. It never mints, stores, or owns a credential of its own. Contrast the heavy default: a service principal + Key Vault + GenerateToken.


04 · THE ARCHITECTUREWhat actually sits in the seam

The important read is what isn't there: no service principal to guard, no embed tokens, no iframe of someone else's UI, no second warehouse. A React app, a relay that forwards a token, a semantic model, and — for realtime — a second relay to a sensor API.

BROWSER EXO · React / Next.js (Vercel) MSAL sign-in · Entra SSO (SAML-federated) Apache ECharts dashboards holds only the user's own delegated token never a secret of ours NEXT.JS API ROUTES · BFF /api/fabric/execute-queries verify delegated token → forward DAX · no secret held /api/fabric/discover list the reports this user can see /api/skyfii/counters Basic cred in env · tenant-gated same-origin · exists because Power BI has no CORS and tokens deserve verification before they travel POWER BI semantic model DAX · measures RLS enforced as the user source of truth BIONIC / SKYFII realtime REST 1-min occupancy series MONGODB read-through cache · per-model TTL fail-open · NOT a source of truth config/models.json models = config, not code IDs · access rule · cache TTL · zod-validated delegated PBI token poll 30s same token, verbatim cache DAX results allow-list TWO RELAYS · ONE RULE · THE SECRET NEVER REACHES THE GLASS
The settled shape. The two relays are the only new things in the path. The Power BI relay holds no secret — it forwards the user's own token; the Skyfii relay holds a Basic credential in env, never in the browser. Mongo is a cache, never the truth.

Three surfaces, one pattern

Strip everything else away and EXO offers three capabilities, each mapped to a source and a query mode. All three reach their data through a same-origin relay, and none of them puts a credential where a browser could find it.

Interactive dashboards — Visitation, SubscriberPower BI semantic model → DAX via executeQueries → rendered in Apache ECharts, our brand end to end.
DAX
Realtime occupancy — BionicSkyfii REST counters → 30-second poll through the credential-holding relay → live ECharts area chart.
POLL
"Just appears" hallsReports the user can already access, listed with their own token → auto-discovered, optionally embedded.
DISCOVER

05 · THE FLOWOne sign-in to a rendered chart

Put the pieces in motion and this is what a staff member sets off when they open a hall. Five moves, most of them invisible, and the only server-side step is verify-and-forward.

User EXO · React Entra ID Relay · BFF Power BI 1 opens a hall sign in · OIDC / PKCE (SAML-federated SSO) 2 delegated Power BI token — the user's own identity 3 POST { dax, workspaceId, datasetId } + Bearer <user token> verify sig · tenant · aud accepts v1 & v2 issuers 4 executeQueries — same token, verbatim rows — RLS already applied as THIS user 5 JSON rows · column names unwrapped ECharts renders
The five-step relay. The only server-side step is verify-and-forward. Row-level security is never something our code computes — Power BI applies it because the token is the real person's.

06 · DAX → ECHARTSThe semantic layer, without the iframe

This is the part that makes it our app. We don't embed a report; we query the semantic model with DAX and draw the result ourselves.

-- a measure query is just DAX over the governed model
EVALUATE ROW(
  "Active",  [# Active Subscribers],
  "New30",   [# New Subscribers(30 Days)],
  "Unsub30", [# Un-Subscribers(30 Days)]
)

The measures were defined once, in the model. We just ask for them by name.

// the hook runs it through the relay; the component draws ECharts
const kpi = usePowerBiQuery(KPI_QUERY, { workspaceId, datasetId });
const option = useMemo<EChartsOption>(() => ({
  series: [{ type: "radar", data: sites.map(s => ({ name: s.site, value: profile(s) })) }],
}), [kpi.data]);
return <EChart option={option} />;   // our chart, our brand, the model's numbers

Because we own the rendering, we get a real design system — brand colours, a radar of site profiles, a gradient trend, semantic-coloured bars — and we validated the categorical palette for colour-blind separation (which is why the overlaid radar caps at three series). Power BI keeps the numbers honest; ECharts makes them ours.

How do you know what to query in a model you didn't build?

We wrote a model explorer: point a new model at it and it introspects the schema with DAX and lists every measure and field.

EVALUATE SELECTCOLUMNS(INFO.VIEW.MEASURES(),
  "Name",[Name], "Table",[Table], "Expression",[Expression])

A war story lives here — see §08.


07 · REALTIME WITH BIONICA second source, the same discipline

Not everything is Power BI. Live visitor occupancy comes from Bionic people-counters via the Skyfii REST API — a completely different shape: a per-minute occupancy series behind HTTP Basic auth.

LIVE OCCUPANCY poll every 30s 'on site now' + intraday curve /api/skyfii RELAY Basic cred in env tenant-gated BIONIC / SKYFII API 1-min occupancy series SAME MUSEUM · SAME BRAND · A TOTALLY DIFFERENT BACKEND
The second relay. Here the relay does hold a credential — but it lives in env, never in the browser, and the relay refuses anyone without a valid tenant token.

The dashboard polls every 30 seconds and renders a live "on site now" number and an intraday curve in ECharts. Same museum, same brand, a totally different backend — and the front end never knows the difference.

One app, two data worlds — a governed semantic model and a live sensor feed — behind two relays that share one rule: the secret never reaches the glass.


08 · THE FABRIC PATHWhat we actually hit, and how we got through

The clean story above was earned. The real ones:

The design principles that fell out of all this

Models are config, not code SERVER

Every hall and model — its IDs, access rule, cache TTL — is declared in config/models.json, zod-validated at load. Adding a dashboard's data is a config edit; only its bespoke chart is code.

source: config/models.json → allow-list → relay

Where the security is decided POWER BI

We never compute "which rows." The relay forwards the user's real token; the semantic model's RLS decides. The app can't over-ask, because it isn't the one asking.

the model asserts authority · our code asserts nothing

The relay owns nothing TOKEN

For the Power BI path, no secret crosses into our code at all. What crosses the wire is the user's own short-lived token — and DAX results already scoped to them.

verified once · forwarded once · thrown away

The "it just appears" bonus

Because the token is the user's own, a /api/fabric/discover call lists exactly the reports they're allowed to see — segregation for free — and any we don't hand-build can be embedded with their AAD token (user-owns-data embed) rather than a service-principal one.


09 · WHAT'S SETTLEDThe decisions we are building on

Open, deliberately

← All writing rummanahmed.com Share on LinkedIn