Articles · Business intelligence · · 7 min read
Power BI Row-Level Security That Does Not Leak
Row-level security fails at the edges — filter direction, bridge tables, aggregations and composite models. The patterns that hold, and how to test them.
By Matthieu de La Mettrie · Founder, Skopura
Row-level security filters a model’s data by who is asking. That sentence is the whole promise, and it is also where the trouble starts: RLS is implemented as a filter on one table, and everything else about whether it holds depends on how filters move through the rest of your model. If you can draw the propagation path from the security table to every fact table in the model, you can reason about the boundary. If you cannot, you are shipping a guess.
What actually leaks when RLS fails?
Rarely a full table dump. The failure modes are quieter than that.
The first is existence disclosure. A slicer bound to a dimension that sits outside the filtered path lists every customer, every project code, every cost centre — including the ones whose rows are correctly hidden. No fact rows leak. The names do. For a model covering acquisitions, tenders or headcount, the names are the sensitive part.
The second is aggregate bleed. A measure resolves against a table the security filter never reaches — an aggregation table, a summary fact loaded separately, a snapshot built for performance. Detail rows are filtered, the total is not, and the gap between them is itself information.
The third is blank-looks-like-broken. A user whose mapping row is missing sees empty visuals and raises a ticket. Someone with model edit rights reproduces it, sees data because RLS does not constrain them, and closes it as not reproducible. Now nobody trusts the report and nobody knows why.
All three are design faults, not bugs. They are visible on the model diagram before anyone opens a report.
Where does the filter stop propagating?
RLS applies a DAX predicate to one or more tables. From there it travels along relationships in the direction those relationships allow. A star schema with single-direction relationships from dimension to fact propagates cleanly in one direction and not the other — which is exactly what you want when the filter is applied at the dimension, and useless when it is applied at the fact.
The dotted edges are the audit. Any table that cannot be reached from the security table by following relationships in their permitted direction is, by construction, unsecured. Write that list down. For each entry, decide one of three things: relate it to the spine, secure it with its own predicate, or remove it from the model. “It only has reference data in it” is a decision too, but make it explicitly and record why.
Static roles or dynamic mapping?
Static roles hard-code the predicate — a role per region, per business unit, per legal entity. Dynamic RLS evaluates the current user against a mapping table at query time. They fail differently.
| Static roles | Dynamic mapping | |
|---|---|---|
| Where the rule lives | In the model definition | In data, refreshed with the model |
| Adding an audience | Model change and republish | Insert a mapping row |
| Failure when membership is stale | User sees another group's rows | User sees nothing |
| Auditability | Read the role predicates | Query the mapping table |
| Blast radius of an error | Scoped to one role | Potentially every user |
| Coupling to identity source | Workspace role assignment | Upstream directory or HR feed |
Dynamic mapping fails closed when a row is missing, which is the right default, and fails open when the mapping pipeline writes the wrong keys, which is the wrong default and is much harder to notice. Treat the mapping table as production data with its own tests, not as a lookup someone maintains in a spreadsheet.
What does a deny-by-default predicate look like?
The predicate should be written so that an unmatched identity resolves to an empty set rather than an unfiltered one. Beware of expressions where a missing mapping collapses to a condition that is trivially true.
// Applied to: Dim Customer
VAR CurrentUser = USERPRINCIPALNAME()
VAR Allowed =
CALCULATETABLE(
VALUES( 'Security Mapping'[CustomerKey] ),
'Security Mapping'[UserPrincipalName] = CurrentUser,
'Security Mapping'[IsActive] = TRUE()
)
RETURN
'Dim Customer'[CustomerKey] IN Allowed
// A hierarchy variant, where the mapping stores a node
// and access flows down the path:
// PATHCONTAINS( 'Dim Org'[OrgPath], LOOKUPVALUE( ... ) )Two details matter here. Calculated columns are evaluated at refresh, outside any user context, so a column that encodes access decisions is computed once for everyone — put the logic in the predicate or in a measure, not in a calculated column. And security filters are not removable by ALL or REMOVEFILTERS inside a measure, which is a genuine safety property: a report author cannot write their way around the boundary. The boundary is broken by model topology, not by DAX.
How do you test a boundary you cannot see?
“View as role” is a smoke test, not a test. It checks the predicate compiles and returns something; it does not check that the something is correct, and it does not exercise the propagation path through every table.
Build a test fixture instead. Take a handful of representative identities — one with a single mapping, one with several, one with a hierarchy node, one with no mapping at all — and define, outside the model, the set of keys each should be able to see. Then evaluate the model as each identity and compare returned key sets against expected key sets, per fact table, per dimension that backs a slicer. Key sets, not totals: two different sets can sum to the same number.
Run this on every model change, not only when the RLS logic changes. The cases that break RLS are usually unrelated edits — a new relationship set to both-directions to make a slicer behave, an aggregation table added for a slow page, a composite model extended to a source with fixed credentials. None of those arrive labelled as a security change.
The no-mapping identity is the most valuable fixture you have. It is the only one that distinguishes “correctly sees nothing” from “model is broken”, and it is the one that catches a predicate that quietly fails open.
When is RLS the wrong boundary entirely?
RLS hides rows. It does not hide the shape of the model: measure names, column names, table structure and the fact that a measure exists at all remain visible. If the existence of a measure called Redundancy Provision is itself the disclosure, row filtering is not the control you need — object-level security or a separate model is.
No boundary needed — one model, one audience
Splitting models has a real cost: metric definitions drift the moment they are maintained twice. If you split, keep the definitions upstream — in the transformation layer, in dbt models or in a shared Fabric or Databricks layer — so the split happens at the presentation boundary and not at the definition boundary. A semantic layer centralises metric definitions so separate reports agree; that property is worth preserving even when the models themselves must diverge.
What belongs in the review?
Treat the security topology as part of the model contract, reviewed on the same cadence as the measures. Three artefacts make that practical. First, the unreachable-table list described above, regenerated whenever relationships change. Second, the identity fixtures and their expected key sets, held in version control next to the model definition. Third, a short record of which tables are deliberately unsecured and why.
None of this is exotic. It is the same discipline you would apply to any other contract between a system and its consumers — except that when this one fails, nobody files a bug, because the report looks exactly as it should.
power-bi · row-level-security · semantic-model · dax
Share on LinkedIn ↗Work with Skopura
Facing this bottleneck in your own estate?
Skopura builds production-grade lakehouse pipelines, evaluation harnesses, and provides fractional data leadership. Thirty-minute call, no deck.