skip to content

Row-Level Security

Restricting rows per user with DAX filters on roles, either static or driven by USERPRINCIPALNAME against a mapping table. Interviewers ask about dynamic RLS because it is the standard answer to 'each manager should see only their own region'.

on this pageshow

questions

6

In Power BI, how does dynamic RLS use USERPRINCIPALNAME() to filter rows per user?

level: middleimportance: must knowfreq 68%

answer

  1. one role instead of one per region
  2. the filter compares a column to the caller
  3. permissions become refreshable data
  4. the mapping table sits on the many side
  5. direction decides whether it reaches the dimension

basics

~20 s

One role filters a user-mapping table with [UserEmail] = USERPRINCIPALNAME(), which returns the signed-in user's UPN. That row set propagates through relationships to the dimensions and facts, so a single role serves every user and permissions become refreshable data.

solid answer

~40 s

Dynamic RLS replaces one role per audience with a single role whose filter compares a column to the current identity. `USERPRINCIPALNAME()` returns the signed-in user's UPN — the same value in Desktop and in the Service, which is why it is preferred over `USERNAME()`, which returns `DOMAIN\user` in Desktop. The simplest shape puts an owner email on the dimension itself and filters `[ManagerEmail] = USERPRINCIPALNAME()`, so the filter flows one-to-many down to the facts. The general shape is a separate permission table (`UserEmail`, `RegionKey`) related to the dimension; because that table sits on the many side, the filter only reaches the dimension if the relationship is bi-directional or has security filtering enabled in both directions. Permissions then live in refreshable data, not in role membership, and role assignment is one Entra group containing everyone.

code

dax · 2 lines
dax
-- Role "Dynamic", filter expression on table UserPermission
[UserEmail] = USERPRINCIPALNAME()

go deeper

for a junior

Know that USERPRINCIPALNAME() returns the signed-in user's UPN and that dynamic RLS compares it to an email column in a mapping table rather than hardcoding regions in role names.

for a middle

Explain the whole path: filter on the mapping table, propagation to the dimension and fact, why the many-to-one direction matters, and why the function cannot live in a calculated column.

for a senior

Demonstrate operating it — entitlement data sourced and refreshed from a system of record, hierarchy flattening, guest-user UPN mismatches, and a test plan covering users with many, one and zero permitted keys.

for a principal

Decide who owns the entitlement dataset and its audit trail, whether the same table should drive access in other tools, and what refresh latency on permission changes is acceptable to your compliance stakeholders.

## The problem static roles have Static RLS hardcodes the permitted value in the role expression: a `West` role filtering `[Region] = "West"`, an `East` role, and so on. With four regions that is tolerable; with four hundred sales territories it is unmaintainable, and every joiner, leaver or territory change is a Power BI admin task. **Dynamic RLS** collapses all of that into one role whose filter is evaluated against the identity of whoever is running the query. ## The identity functions - `USERPRINCIPALNAME()` returns the signed-in user's User Principal Name — normally their email-shaped Entra sign-in name. It returns the UPN both in Power BI Desktop and in the Power BI Service, which is what makes it the recommended choice. - `USERNAME()` returns the UPN in the Service but `DOMAIN\username` in Desktop, so a model that works when published can behave differently while you develop it. Use it only when you deliberately want that value. - `CUSTOMDATA()` returns a value passed on the connection, which is how embedded scenarios can supply an identity that is not an Entra user. A critical rule: these functions belong in **role filter expressions and measures**, never in a calculated column. Calculated columns are materialised during refresh, under the refreshing identity, so a column defined as `[Email] = USERPRINCIPALNAME()` freezes one answer for everybody. ## Pattern A — the identity on the dimension If each dimension row has exactly one owner, put the owner's email on the dimension and filter it directly: ``` [ManagerEmail] = USERPRINCIPALNAME() ``` The filter reduces the dimension to that manager's rows and propagates one-to-many to every related fact. No bridge, no bi-directional relationship, minimal cost. It fails as soon as one row may be visible to several people. ## Pattern B — a user-permission table The general shape is an entitlement table, one row per (user, permitted key) pair: ``` UserPermission(UserEmail, RegionKey) ``` related many-to-one to `DimRegion`, with the role filter `[UserEmail] = USERPRINCIPALNAME()` on `UserPermission`. Here the filtered table is on the **many** side of the relationship, and filters do not travel from many to one by default. Either the relationship must be bi-directional, or you must enable security filtering in both directions on it, or the filter simply never reaches the dimension and the user sees everything. This is the single most common dynamic-RLS bug, and it is worth saying out loud in an interview because it shows you understand that RLS is nothing more than filter propagation. The entitlement table itself is ordinary data: it can come from an HR system, an access-request app, or a warehouse table maintained by the data team, and it updates on refresh. Two consequences follow. A permission change takes effect only after the next semantic-model refresh, and the entitlement table is now sensitive data inside the model — it should be hidden from the field list, and it is itself readable by nobody except through the role filter. ## Hierarchies "A manager sees their own team and everyone beneath them" is not a single join. The usual answer is to flatten the hierarchy upstream into the entitlement table — one row per (manager, employee) ancestor pair — so the DAX stays a plain equality test. Path functions such as `PATH` and `PATHCONTAINS` can express ancestry inside the model instead, but they cost more per query and are harder to reason about than a pre-computed bridge. ## Role membership With dynamic RLS there is normally exactly one role, and *everyone who consumes the report* is added to it — typically as a single Entra security group. A read-only user who is a member of **no** role cannot see the model's data at all, so forgetting to add a new consumer group looks like a broken report rather than a leak, which is the safe failure direction. ## Matching and hygiene DAX string comparison is not case-sensitive, so casing differences in stored emails will not break the match, but leading spaces, aliases, and stale addresses will. If the UPN people sign in with differs from the email your HR system stores, the join must be on the UPN — this mismatch is a frequent production incident. Guest (B2B) users sign in with their own UPN, which may not resemble your domain at all. ## Testing Desktop can view the report as a role and as another user, and the Service can test a role on the published model, so you can confirm the mapping without borrowing someone's account. Always test at least one user with several permitted keys, one with none, and one who is absent from the entitlement table entirely.

  • Why prefer USERPRINCIPALNAME() over USERNAME() in a role filter?
    `USERPRINCIPALNAME()` returns the UPN in both Power BI Desktop and the Service, so what you test locally is what runs after publishing. `USERNAME()` returns `DOMAIN\username` in Desktop and the UPN in the Service, so a model that matched correctly during development can match nothing once published — a confusing failure that looks like a data problem rather than a function-choice problem.
  • Your entitlement table relates many-to-one to the dimension and users still see every row. What is wrong?
    Filters propagate from the one side to the many side by default, not the reverse. The filter lands on the entitlement table and stops there. Make the relationship bi-directional or enable security filtering in both directions on it, then re-test. The alternative is to move the identity column onto the dimension itself so the filter starts on the one side.
  • A user's permissions changed this morning but the report still shows the old rows. Why?
    The entitlement table is data in the semantic model, so a change at the source is only visible after the next refresh of that model — in import mode, the mapping rows in the model are yesterday's copy. Either refresh the model, or put the entitlement table in DirectQuery so the lookup is read live at query time.
  • Can you evaluate USERPRINCIPALNAME() in a calculated column to pre-compute visibility?
    No. Calculated columns are materialised during data refresh, under the refreshing identity, so every user would inherit the same frozen value. The identity functions must be used where evaluation happens per query — a role filter expression or a measure.

saying these in an interview costs you the question

  • Creating one static role per region and calling it dynamic
  • Using USERNAME() and being surprised by DOMAIN\user in Desktop
  • Putting USERPRINCIPALNAME() in a calculated column
  • Forgetting the mapping table's filter must travel many-to-one
  • Expecting permission changes to apply without a model refresh

context

open as a page

In Power BI, what does a row-level security role do to the data a report user sees?

level: middleimportance: must knowfreq 75%

basics

~20 s

A Power BI role holds a DAX boolean filter on one or more tables. The engine applies it to every query the assigned user runs and propagates it along relationships, so excluded rows never reach visuals, totals or exports.

open as a page

In Power BI Desktop, how do you check what an RLS role sees before publishing?

level: juniorimportance: should knowfreq 42%

basics

~20 s

Use Desktop's View as feature to render the report under a chosen role, and for dynamic RLS also as another user by typing their UPN. After publishing, the semantic model's security page in the Power BI Service can test the report as a role.

open as a page

A Power BI RLS role filters DimRegion, yet the fact table still shows every row — why?

level: seniorimportance: should knowfreq 48%

basics

~20 s

The security filter only travels where model relationships carry it. Look for a missing, inactive or wrongly directed relationship, a fact table joined to a different dimension copy, or a disconnected table — and confirm the user is not exempt through workspace edit rights.

open as a page

In the Power BI Service, which users bypass RLS on a semantic model, and why?

level: seniorimportance: should knowfreq 45%

basics

~20 s

Users with workspace Admin, Member or Contributor roles hold edit permission on the semantic model, and RLS is not applied to them. Roles restrict read-only consumers — Viewers and app audiences — and a read-only user assigned to no role sees no data.

open as a page

Should row filtering live in Power BI RLS or in the warehouse when several tools read the same data?

level: principalimportance: nice to knowfreq 30%

basics

~20 s

Enforce once, as close to the data as the access pattern allows. Warehouse row policies with DirectQuery and single sign-on cover every tool but cost per query; Power BI RLS is faster and self-contained but re-implements the rule for one tool over a cached copy.

open as a page