
Picture this: your organization has just finished building a beautiful sales dashboard in Power BI. Regional managers love it. Executives are using it daily. Then someone in finance notices that the Southeast regional manager can see the Northeast region's numbers — and those numbers include some sensitive commission data that was supposed to stay regional. Suddenly you have a security incident, a panicked call to IT, and a report that can't be trusted until it's fixed. What went wrong? Row-Level Security (RLS) was either never implemented, or it was implemented incorrectly.
RLS is one of the most critical and most misunderstood features in Power BI. It sits at the intersection of data modeling, DAX, and enterprise identity management, which means getting it right requires competence across all three. Done poorly, RLS gives you false confidence — users feel restricted, but a determined person can often find the gaps. Done well, it's nearly invisible: users see exactly the data they're authorized to see, the rules maintain themselves as the organization changes, and your security posture is genuinely airtight.
By the end of this lesson, you will be able to design and implement both static and dynamic RLS in Power BI, write DAX security expressions that handle real-world complexity, test your rules rigorously before deployment, and roll out RLS in enterprise environments where identity comes from Azure Active Directory groups and where dataset refresh, organizational hierarchy, and multi-role scenarios all need to work together.
What you'll learn:
USERPRINCIPALNAME() and a security mapping table — the pattern used in most production deploymentsYou should be comfortable with:
If you're fuzzy on filter context in DAX, pause here and shore that up first. RLS is filter context — you cannot implement it correctly without understanding it.
Before writing a single DAX expression, you need to understand what's actually happening when Power BI enforces security. This isn't academic — it directly determines how you structure your data model and where you place your security filters.
When a user opens a report, Power BI Service authenticates them via Azure AD and determines their identity. That identity (their User Principal Name, or UPN — typically their work email address) gets passed to the Analysis Services engine that powers the Power BI dataset. The engine then applies whatever RLS rules are defined for the roles that user belongs to. Only after those filters are applied does the engine execute the DAX queries that drive your visuals.
This means the security filter runs before your report queries. Your report DAX can't escape it. A measure that uses ALL() to ignore filters? It still can't see rows that were filtered out by RLS. A visual that uses ALLSELECTED()? Same story. This is good news — it means RLS is genuinely enforced at the engine level, not bolted on top at the visualization layer. But it also means you need to be thoughtful about where in your model you place the security filter, because filter propagation through relationships determines what gets restricted.
Here's the practical consequence: your RLS filter applies to a specific table. Power BI then propagates that filter through your model relationships to restrict related tables. The direction of that propagation depends on your relationship configuration.
In a classic star schema, you have a fact table (like Sales) and dimension tables (like Region, Product, Date). If you apply an RLS filter to the Region dimension table, the filter propagates to Sales through the one-to-many relationship, restricting which sales rows a user can see. This is the correct pattern for most scenarios.
What you want to avoid is applying your security filter directly to the fact table. It works, but it creates a maintenance nightmare — every new fact table needs its own filter. Filter from the dimension; let relationships do the work.
Region (dimension) ──[1:*]──> Sales (fact)
^
│
RLS filter applied here
│
Filter propagates to Sales automatically
Bidirectional relationships deserve special caution. If your Region to Sales relationship is set to filter in both directions, then filtering Sales would also filter Region — which is usually not what you want for RLS. Stick to single-direction filtering in your security-related relationships unless you have a very specific reason otherwise and you've tested it thoroughly.
Static roles are where everyone starts, and they're the right choice for organizations with a small number of stable security groupings. Let's implement one properly so you understand the mechanics before we move to dynamic rules.
Imagine you're building a sales dashboard for a retail company with four regions: Northeast, Southeast, Midwest, and West. Each regional manager should only see their region's data.
In Power BI Desktop, open the Modeling ribbon and select "Manage Roles." Click "Create" and name your first role Northeast_Manager. In the table pane, find your Region dimension table and click the ellipsis next to it. Enter this DAX expression:
[RegionName] = "Northeast"
Create three more roles — Southeast_Manager, Midwest_Manager, and West_Manager — with corresponding filter expressions. Save and close.
Now, here's what this actually does: Power BI wraps this expression in a FILTER() applied to the Region table at query time. When someone assigned to the Northeast_Manager role runs any query, the engine behaves as though the Region table only contains the row where RegionName = "Northeast". That restriction then flows through your relationships to limit the Sales rows, the Budget rows, and any other fact table connected to Region.
Static roles work fine for four regions. They start to become a problem when you have 40 regions, or when regions change, or when the same person manages two regions, or when a user is promoted and their access needs to change. Every structural change requires opening Power BI Desktop, modifying roles, and republishing the dataset. In a live production environment, that's an operational burden that compounds over time.
More fundamentally, static roles encode your organization's structure into your data model. When the organization changes, the model must change. The enterprise-grade answer is dynamic RLS.
Dynamic RLS decouples your security configuration from your data model definition. Instead of hardcoding filter values into role expressions, you store the mapping between users and their data access in a table — a security mapping table — and write a DAX expression that looks up the current user's access at query time.
This is the pattern used in virtually every serious Power BI deployment. Here's how to build it.
Your security mapping table stores the relationship between user identities and the dimension values they're allowed to see. For our retail example:
| UserEmail | RegionName |
|---|---|
| sarah.chen@company.com | Northeast |
| sarah.chen@company.com | Midwest |
| james.okafor@company.com | Southeast |
| priya.sharma@company.com | West |
| david.kim@company.com | Northeast |
| david.kim@company.com | Southeast |
| david.kim@company.com | Midwest |
| david.kim@company.com | West |
Notice that Sarah Chen has access to two regions — she's probably a divisional manager. David Kim has access to all four — he's probably an executive or a national sales director. This is something static roles cannot handle gracefully; dynamic RLS handles it naturally.
This table typically comes from one of three places:
Option 3 is the most scalable and the most secure, because it derives access from your authoritative identity system automatically. We'll return to this in the enterprise deployment section.
In your Region table, you create a single role — call it Regional_Access — and apply this DAX filter expression:
[RegionName] IN
CALCULATETABLE(
VALUES( SecurityMapping[RegionName] ),
SecurityMapping[UserEmail] = USERPRINCIPALNAME()
)
Let's walk through what this does. USERPRINCIPALNAME() returns the UPN of the currently authenticated user — their email address, in most Azure AD configurations. The CALCULATETABLE expression then filters the SecurityMapping table down to only rows where that user's email appears, and from those rows extracts the distinct RegionName values. Finally, the IN operator checks whether the current Region row's RegionName is in that list.
The result: each user sees exactly the regions mapped to them in the SecurityMapping table. Change the table, and access changes immediately on the next query — no model republishing required.
Important:
USERPRINCIPALNAME()returns the authenticated user's identity when the report is accessed through Power BI Service. In Power BI Desktop, it returns your own Windows identity or email, which is why "View As Role" testing is essential before deployment. We'll cover testing thoroughly in a dedicated section.
The SecurityMapping table needs a relationship to your Region table so that filters can propagate correctly. Create a many-to-one relationship from SecurityMapping[RegionName] to Region[RegionName]. This relationship should filter in both directions — this is one of the cases where bidirectionality is actually appropriate, because the security mapping table exists solely to drive security filtering and doesn't participate in your analytical queries.
Actually, let's be precise about an alternative approach many practitioners prefer: don't rely on the relationship at all. Keep the DAX expression self-contained using CALCULATETABLE as shown above. This approach is more explicit about what's happening and less vulnerable to unexpected relationship interactions. The expression directly queries the SecurityMapping table and returns the valid RegionName values, then checks membership in that set.
SecurityMapping
┌────────────────────┬────────────────┐
│ UserEmail │ RegionName │
├────────────────────┼────────────────┤
│ sarah.chen@... │ Northeast │
│ sarah.chen@... │ Midwest │
│ james.okafor@... │ Southeast │
└────────────────────┴────────────────┘
Region
┌────────────────┐
│ RegionName │ Filter: [RegionName] IN {user's allowed regions}
├────────────────┤ ↓
│ Northeast │ ←── Visible to sarah.chen
│ Southeast │ ←── NOT visible to sarah.chen
│ Midwest │ ←── Visible to sarah.chen
│ West │ ←── NOT visible to sarah.chen
└────────────────┘
Real organizations aren't flat. You have individual contributors, regional managers, divisional managers, and executives — all with different scopes of data access that relate to each other through the organizational hierarchy. If you encode each person's access as individual rows in the security mapping table, you end up with an enormous table that's painful to maintain whenever the hierarchy changes.
The better approach is to think about hierarchy-based access in two ways: explicit enumeration for smaller organizations, and recursive hierarchy traversal for larger ones.
For most mid-market organizations, the security mapping table approach with role-based rather than user-based entries is the cleanest solution. Instead of mapping individual users to regions, you map Azure AD security groups to regions:
| GroupEmail | RegionName |
|---|---|
| northeast-managers@company.com | Northeast |
| divisional-east@company.com | Northeast |
| divisional-east@company.com | Southeast |
| national-sales@company.com | Northeast |
| national-sales@company.com | Southeast |
| national-sales@company.com | Midwest |
| national-sales@company.com | West |
But wait — Power BI's USERPRINCIPALNAME() returns the individual user's UPN, not their group memberships. So you can't directly compare UPN to group emails in the filter expression.
You have two options. First, you can expand the group memberships into individual user rows during your data refresh process by querying Azure AD (via Microsoft Graph API or a Power Query connector). The mapping table then contains individual user emails, and the DAX expression works as previously shown.
Second, you can use Power BI's built-in support for mapping Azure AD security groups to roles at the Service level (which we'll cover in deployment). In this case, you define roles in the model, and Azure AD group membership determines which role a user gets — the actual role assignment happens outside DAX.
If your organization has a true reporting hierarchy stored in your data — like an employee table with a ManagerID column — you can implement manager-level access using DAX path functions. Here's a scenario: a sales manager should see all transactions belonging to everyone in their reporting chain.
Start with an Employee table:
| EmployeeID | EmployeeName | ManagerID | |
|---|---|---|---|
| 1 | Maria Torres | NULL | m.torres@company.com |
| 2 | James Liu | 1 | j.liu@company.com |
| 3 | Amy Park | 2 | a.park@company.com |
| 4 | Chris Webb | 2 | c.webb@company.com |
Use DAX's PATH function to precompute the hierarchy path for each employee:
HierarchyPath =
PATH( Employee[EmployeeID], Employee[ManagerID] )
This creates a string like "1|2|3" for Amy Park, encoding the full chain from the root to her. Now, your RLS expression on the Employee table becomes:
PATHCONTAINS(
Employee[HierarchyPath],
LOOKUPVALUE(
Employee[EmployeeID],
Employee[Email],
USERPRINCIPALNAME()
)
)
This expression says: for each employee row, check whether the current user's EmployeeID appears anywhere in that employee's hierarchy path. If Maria Torres (EmployeeID 1) is logged in, she sees everyone, because 1 appears in every path. If James Liu (EmployeeID 2) is logged in, he sees himself, Amy, and Chris, but not Maria's other direct reports if she had any. Amy Park sees only herself.
This is elegant and self-maintaining — as long as your employee data is current, the security logic is correct without any manual intervention.
Performance note: The
PATHandPATHCONTAINSfunctions work by computing and traversing string representations of hierarchies. On very large employee tables (50,000+ rows), this can introduce noticeable query latency. Profile your query performance in such cases and consider precomputing and materializing the hierarchy mapping in your ETL process rather than computing it in DAX.
What happens when a user belongs to multiple roles? In Power BI, multiple role assignments are additive — the user sees the union of all data accessible through each role they're assigned to. This is an OR relationship, not AND.
This is important and often misunderstood. If you assign a user to both Northeast_Manager and Southeast_Manager, they see both regions. You cannot use roles to implement an AND restriction (show only data that's in Region A AND Category B). For that kind of compound restriction, you need to build it into a single role's DAX expression.
For executives who need to see all data, you have two patterns:
Pattern 1: Executive bypass in the DAX expression
USERPRINCIPALNAME() IN VALUES( ExecutiveList[Email] )
|| [RegionName] IN
CALCULATETABLE(
VALUES( SecurityMapping[RegionName] ),
SecurityMapping[UserEmail] = USERPRINCIPALNAME()
)
This first checks if the user is in an executive list and, if so, returns TRUE for all rows (bypassing the filter entirely). Otherwise, it falls back to the standard access check.
Pattern 2: Comprehensive mapping table
Add executives to your SecurityMapping table with rows for every region. This is simpler from a DAX perspective but requires maintenance whenever you add new dimension values. The executive bypass pattern in DAX is more resilient to schema changes.
Warning: If you're using Power BI Report Server (on-premises) rather than Power BI Service,
USERPRINCIPALNAME()returns the Windows domain account (DOMAIN\username format, not email). This breaks email-based lookups. On-premises deployments should useUSERNAME()and store domain accounts in the mapping table instead.
Inadequate testing is the most common reason RLS deployments fail in production. You need to verify that each role shows the right data, that no role shows data it shouldn't, and that users who belong to no role don't see any data they shouldn't.
The "View As Role" feature in Power BI Desktop is your primary testing tool during development. On the Modeling ribbon, find "View As" (it may be labeled "View as roles" depending on your Desktop version). Select a role, and optionally specify a specific UPN to simulate. Your report immediately re-renders showing only the data that user/role combination would see.
Critical things to check for each role:
ALL() incorrectly.When specifying a UPN to test dynamic RLS, use a real email from your security mapping table. Enter sarah.chen@company.com (one of your mapped users) and verify she sees Northeast and Midwest data. Then enter a UPN that doesn't exist in your mapping table and verify that she sees no data at all — this tests your "zero access" edge case.
This is the edge case most developers forget. What happens when a user's UPN doesn't appear in your SecurityMapping table? Let's trace through the DAX:
[RegionName] IN
CALCULATETABLE(
VALUES( SecurityMapping[RegionName] ),
SecurityMapping[UserEmail] = USERPRINCIPALNAME()
)
If USERPRINCIPALNAME() returns a UPN that has no rows in SecurityMapping, then the CALCULATETABLE expression returns an empty table. VALUES() of an empty table is an empty list. And [RegionName] IN {} (empty set) evaluates to FALSE for every row. The user sees zero data.
This is correct behavior — unknown users should see nothing. But make sure it's actually happening. Test with a UPN that isn't in your table. If your user accidentally sees all data instead of no data, you have a bug in your DAX, and you have a serious security hole.
After publishing, you can test role assignments in the Service by going to your dataset's Security settings (find the dataset in your workspace, click the ellipsis, select "Security"). Here you can add individual users or Azure AD groups to roles and use the "Test As Role" option to preview what a role member would see.
The Service-level testing is essential because it confirms that the identity resolution (UPN lookup from Azure AD) is working correctly. A common failure mode is that a user's UPN in Azure AD differs from what you have stored in your security mapping table — perhaps due to a domain change, an alias, or a recently hired employee whose email was added to the report data but who used their old email format during login.
For enterprise deployments with dozens of roles and hundreds of users, manual testing doesn't scale. The Power BI REST API provides endpoints that allow you to validate effective permissions programmatically.
The POST /datasets/{datasetId}/generateTokenInGroup endpoint allows you to generate an embed token for a specific identity, which you can then use to validate what that identity sees. More directly relevant for testing is using the Analysis Services client libraries to execute DAX queries against your published dataset while impersonating specific users — this lets you write automated tests that verify expected row counts for each user/role combination.
For a production deployment, consider building a test harness that:
This kind of automated security validation, run after each dataset refresh and after each dataset publish, gives you genuine confidence that your RLS is functioning correctly rather than security theater.
RLS interacts with DAX measures in ways that aren't always obvious. Understanding these interactions prevents bugs that are difficult to diagnose.
RLS operates at the filter context level, above individual measure calculations. When a measure uses CALCULATE with ALL() to remove filters:
All Regions Sales = CALCULATE( SUM( Sales[Amount] ), ALL( Region ) )
This removes user-applied slicers and cross-filters from the Region table — but it does not remove the RLS filter. The RLS filter is a different kind of context, applied at a lower level that ALL() cannot override. So All Regions Sales for a Northeast-only user will still only return Northeast sales, even though you said ALL(Region). This is the intended behavior and a security feature, but it can confuse users who expect to see comparison metrics against the full dataset.
If you need to show a user a metric for the full company (for context) while still restricting their detailed data view, you're in a genuinely complex situation. The only clean solution is to pre-aggregate those "total company" numbers in a separate table that isn't subject to RLS (a summary table at a non-RLS-restricted grain), or to use a separate dataset without RLS for the benchmark metrics and combine them in a composite model.
A common mistake is using RELATED() in RLS expressions when the required related table isn't in the right filter context. RELATED() only works within row context (like in calculated columns), not in filter context. Since RLS DAX expressions operate as filter expressions, use LOOKUPVALUE() when you need to fetch a value from another table:
-- Wrong: RELATED doesn't work in filter expressions
[SalesTerritory] = RELATED( Employee[Territory] )
-- Right: LOOKUPVALUE works in filter expressions
[SalesTerritory] = LOOKUPVALUE(
Employee[Territory],
Employee[Email],
USERPRINCIPALNAME()
)
An important gotcha: RLS does not apply to calculated columns. Calculated columns are computed during the data refresh, not at query time, so the RLS filter context isn't active when they're computed. If your calculated column contains data that should be protected by RLS, a user who accesses the column values through DAX could potentially see information outside their allowed scope. This is one reason to keep sensitive derivations in measures rather than calculated columns when you're in an RLS-restricted model.
Moving from development to enterprise deployment introduces a set of considerations that don't exist in Desktop.
When you publish a dataset from Power BI Desktop to the Service, the role definitions come with it. Go to your workspace, find the dataset, click the ellipsis, and select "Security." Here you'll see the roles you defined in Desktop and fields to add members.
You can add:
For any organization beyond a handful of users, always use Azure AD security groups rather than individual user accounts. Here's why: when a new sales rep joins the Northeast team, IT adds them to the Northeast-Sales-Team AAD group. Power BI automatically includes them in the Northeast_Manager role because the group is assigned to that role. No Power BI admin needs to touch the dataset. When someone leaves, IT removes them from the group — again, Power BI access is automatically revoked. The security follows your authoritative identity system without any manual reconciliation.
Here's a pattern that gives you the best of both worlds. You define one role in your model — Standard_User — with a dynamic DAX expression. In the Service, you assign your broad All-Power-BI-Users Azure AD group to that role. The dynamic DAX expression then handles the fine-grained filtering based on the SecurityMapping table.
This means you manage two things separately:
SecurityMapping table and the DAX expressionSeparating these concerns makes your security architecture cleaner. IT manages authentication and basic access (AAD groups). Data stewards manage the SecurityMapping table. The two systems don't need to be synchronized manually.
Your SecurityMapping table needs to be kept current. If you're sourcing it from a database, this is straightforward — include it in your regular dataset refresh. But if it's large or if you want access changes to take effect quickly, consider setting up an incremental refresh policy that only refreshes the security table (or isolates it in a composite model's DirectQuery partition).
For the fastest possible access updates, the most sophisticated pattern is to store the SecurityMapping table in a DirectQuery-connected source (like Azure SQL Database or Azure Synapse) rather than in Import mode. Changes to the database table take effect on the next query without requiring any dataset refresh. The trade-off is a small query performance cost — the security lookup happens in real time against the database rather than in-memory.
If you're embedding Power BI reports in an application using Power BI Embedded (the Azure-based embedding service), RLS works differently. Instead of authenticating via Azure AD, your embedding application generates a token that specifies the user's effective identity and role. This is called "Effective Identity."
In this pattern, your application code — not the Power BI Service — is responsible for determining what role to assign and what username to pass. The token generation call looks like:
{
"accessLevel": "View",
"identities": [
{
"username": "sarah.chen@company.com",
"roles": ["Regional_Access"],
"datasets": ["your-dataset-id"]
}
]
}
The Power BI engine then treats the embedded session as if that user with that role is authenticated. Your dynamic DAX expression fires with USERPRINCIPALNAME() returning sarah.chen@company.com, and the security mapping lookup runs exactly as it would in the Service.
The critical security implication: your embedding application's token generation code is now the security boundary. If a bug allows a user to request a token with someone else's username or a higher-privilege role, your RLS is completely bypassed. Application-level security in embedded scenarios requires the same rigor — arguably more — as the RLS configuration itself.
In this exercise, you'll build a complete dynamic RLS implementation for a sales dataset from scratch. Here's the scenario: you're building a Regional Sales Performance dashboard for a national retail chain. Regional managers should only see their regions; divisional VPs see their division's regions; the national director sees everything.
Step 1: Prepare your data model
Create (or import) the following tables:
A Region dimension table with columns: RegionID, RegionName, Division
RegionID | RegionName | Division
---------|-------------|----------
1 | Northeast | East
2 | Southeast | East
3 | Midwest | Central
4 | Southwest | Central
5 | West | West
6 | Northwest | West
A Sales fact table with columns: SaleID, RegionID, ProductID, SaleDate, Amount
A SecurityMapping table with columns: UserEmail, RegionName
Populate SecurityMapping with data reflecting this org structure:
dvp.east@retailco.com): Northeast and Southeastdvp.central@retailco.com): Midwest and Southwestdvp.west@retailco.com): West and Northwestnd@retailco.com): all six regionsghost@retailco.com): no rowsStep 2: Set up relationships
Create a many-to-one relationship from Sales[RegionID] to Region[RegionID] with single-direction filtering (Region → Sales).
Create a many-to-one relationship from SecurityMapping[RegionName] to Region[RegionName]. Set this to single-direction filtering as well — we'll use the explicit DAX approach rather than relationship propagation.
Step 3: Create the dynamic role
In the Modeling ribbon, open Manage Roles and create a role called Regional_Access. Apply this filter to the Region table:
[RegionName] IN
CALCULATETABLE(
VALUES( SecurityMapping[RegionName] ),
SecurityMapping[UserEmail] = USERPRINCIPALNAME()
)
Step 4: Create a total sales measure
In your Sales table, create a measure:
Total Sales = SUM( Sales[Amount] )
Create a second measure to help with testing — this one should return the count of visible regions:
Visible Region Count = COUNTROWS( Region )
Step 5: Build a simple test report
Create a table visual with Region[RegionName] and [Total Sales]. Create a card visual with [Visible Region Count]. These will be your test visuals.
Step 6: Test thoroughly
Use "View As" in the Modeling ribbon to test the following scenarios:
Regional_Access with UPN dvp.east@retailco.com — you should see Northeast and Southeast, Visible Region Count = 2Regional_Access with UPN nd@retailco.com — you should see all six regions, Visible Region Count = 6 Regional_Access with UPN ghost@retailco.com — you should see zero rows, Visible Region Count = 0Step 7: Add an executive bypass
Modify your role filter expression to add an executive override check that lets a user see all data if their UPN starts with a specific executive domain prefix (or better: is in a separate ExecutiveList table you create). Practice modifying the DAX without breaking the existing access logic:
VAR CurrentUser = USERPRINCIPALNAME()
VAR UserRegions =
CALCULATETABLE(
VALUES( SecurityMapping[RegionName] ),
SecurityMapping[UserEmail] = CurrentUser
)
RETURN
[RegionName] IN UserRegions
Notice we've refactored to use variables — this is a best practice for readability and also allows you to compute CurrentUser once and reference it twice. Extend this with your executive check using the COUNTROWS of an ExecutiveList lookup.
Bonus challenge: Modify the SecurityMapping table to use Division-level access entries. A VP with Division = "East" should see all regions in that division without individually listing each region. Write the DAX expression that handles this by joining through the Region table's Division column rather than looking up RegionName directly.
This almost always means you've published the dataset but haven't assigned any users to the role in the Service. Check your dataset's Security page. An unassigned role affects no one — users who aren't assigned to any role see all data by default (for the workspace members who have the right dataset access). Assign your Azure AD group to the role immediately after publishing.
First, check UPN format. USERPRINCIPALNAME() returns the exact UPN from Azure AD — typically firstname.lastname@company.com. If your SecurityMapping table has emails in a different format (different domain, different convention, or lowercase vs. mixed case), the lookup fails. DAX string comparisons are case-insensitive in most contexts, but domain differences are real.
Second, check whether the user is actually assigned to the role in the Service. If a user is assigned to a role with a DAX expression that filters out all their rows, they'll see nothing. But if they're not assigned to any role, they should see everything — so seeing nothing means they're assigned to a role whose filter returns empty for them.
Third, test the USERPRINCIPALNAME() return value by temporarily creating a measure:
CurrentUPN = USERPRINCIPALNAME()
Put this in a card visual and view it as the affected user (using "Test As" in the Service). This tells you exactly what UPN the engine is seeing.
If your SecurityMapping table has hundreds of thousands of rows (common in large enterprises with fine-grained access), the CALCULATETABLE lookup can become expensive. Optimizations to consider:
UserEmail so that the ETL refresh is fast.If your dataset uses DirectQuery to a source that supports its own row-level security (like Azure SQL Database with RLS, or SQL Server with row-level security), you may encounter a situation where Power BI's RLS and the database's own RLS interact. Be explicit about which layer is doing the filtering to avoid double-filtering (which causes empty results) or no filtering (which causes security gaps).
The safest approach: if Power BI RLS is your primary control, disable or set database-level RLS to pass-through. If database-level RLS is your primary control (because of compliance requirements that mandate database-layer security), you can configure Power BI to pass the user's identity through to the database — this is called Fixed Identity vs. Per-User credentials in the dataset settings.
Power BI workspace members with the Admin, Member, or Contributor role can see all data in datasets within that workspace — RLS does not apply to them. Only users with the Viewer role (or those who access the report through an app) are subject to RLS. This is by design, on the assumption that people with edit rights need to see all data to build reports properly. If you need to restrict workspace members too, you need to separate the dataset and the report into different workspaces, publish the report to a workspace where the restricted users have Viewer access.
You've now covered the complete arc of RLS implementation — from understanding how the Analysis Services engine enforces security filters, through static and dynamic role design, into hierarchical access patterns, DAX measure interactions, and enterprise deployment with Azure AD.
The key mental model to carry forward: RLS is filter context applied at the identity layer, before any of your report's DAX executes. Design your security filters to operate on dimension tables and let relationship propagation do the work. Use dynamic RLS with a security mapping table for any organization that changes over time (which is every organization). Test obsessively — the happy path, the edge cases, and especially the zero-access case.
For next steps, here's where to deepen your expertise:
Object-Level Security (OLS): While RLS restricts which rows a user can see, OLS restricts which columns and tables are visible at all. It's Power BI's answer to column-level security and is complementary to RLS. Implement OLS when you have columns (like salary, commission rate, or PII fields) that should be entirely invisible to certain users rather than just filtered.
Composite Models and RLS: When you combine Import and DirectQuery tables in a composite model, RLS rules become more complex — each storage mode has its own security enforcement path. If you're building composite models in production, the RLS interactions are a critical area to study.
Power BI Activity Log and Security Auditing: Once your RLS is deployed, you need to know if it's working. The Power BI Activity Log (accessible via REST API or Microsoft 365 Compliance Center) records every report view event, including which user accessed which report. Combine this with programmatic RLS testing to build a continuous security monitoring pipeline.
Sensitivity Labels and Information Protection: Microsoft Purview sensitivity labels integrate with Power BI to add an additional layer of access governance. Understanding how sensitivity labels and RLS work together — and where one supplements the other — is important for organizations with strict data classification requirements.
Mastering RLS is one of the highest-leverage investments you can make as a Power BI practitioner. Security done right is invisible to users and invulnerable to attackers. Now go audit the RLS on your existing dashboards — you may be surprised what you find.
Learning Path: Getting Started with Power BI