Row-Level Security in Microsoft Fabric isn't just a T-SQL exercise — it's an architecture decision with serious implications for workspace roles, Power BI Direct Lake, and Spark access paths. This lesson walks through building dynamic RLS policies from scratch, understanding where the security envelope stops, and verifying your policies work as intended before real users hit production.

Imagine your company operates across four sales regions — Northeast, Southeast, Midwest, and West. Your data warehouse holds every transaction from every region in a single sales.Orders table. The regional VP of Sales for the Midwest has just been granted access to the Fabric workspace, and she can now query the full table. Every row. Including the numbers her counterparts in the other regions are posting. That's not a data governance gap — it's a liability.
Row-Level Security (RLS) is the mechanism that closes it. Rather than creating separate filtered views or maintaining four separate copies of the data — each a maintenance headache and a consistency risk — you define a policy that filters rows invisibly, at the engine level, based on who is running the query. The Midwest VP runs the exact same query as the West VP, sees the exact same columns, but the engine returns only the rows that belong to them. No special views required. No data duplication.
By the end of this lesson, you will be able to implement production-ready RLS on both a Fabric Warehouse and a Lakehouse SQL Analytics Endpoint, understand the meaningful architectural differences between the two, and test your policies rigorously — including impersonating a business user to verify that the policy behaves exactly as intended before it ever touches a real user's session.
What you'll learn:
CREATE SECURITY POLICY works in a Fabric Warehouse, and the exact syntax differences from Azure SQL DatabaseEXECUTE AS USER and external user impersonationYou should already be comfortable with:
CREATE TABLE, CREATE VIEW, CREATE FUNCTION, CREATE SCHEMAYou do not need to be a T-SQL security expert, but you should understand the basics of schemas, functions, and what a predicate is.
Before you write a single line of T-SQL, you need a clear mental model of how RLS actually works. Misunderstanding this leads to one of two failure modes: a policy that's too permissive (users see rows they shouldn't), or a policy that's too restrictive (users see nothing, or the wrong thing, and blame the data team).
RLS in SQL Server, Azure SQL, and Fabric Warehouse is implemented through security predicates. A security predicate is a function that takes a row as input and returns 1 (visible) or 0 (invisible). That function gets called for every row the engine would otherwise return. You wire the predicate to a table via a security policy object.
There are two types of predicates:
For most analytical use cases — regional sales data, department financials, patient records — you want filter predicates. You don't usually need block predicates on a warehouse because business users are reading, not writing.
Here's the architecture of what you're building:
[Query from User A]
↓
[SQL Engine evaluates SELECT against sales.Orders]
↓
[For each candidate row, engine calls fn_SecurityPredicate(Region)]
↓
[Function checks: does User A map to this Region in dbo.UserRegionMap?]
↓
[Returns only rows where function returns 1]
↓
[User A sees their data. No error. No hint about other rows.]
The function is the brain. The policy is the wiring. The mapping table is the data.
Key insight
RLS filtering happens after the parser but before results are returned to the client. This means RLS filters apply even when a user queries through a view, through a stored procedure, or when Power BI issues a query on behalf of that user — as long as the connection is made with that user's identity. The filter is not something the user can bypass with clever SQL.
We're going to build a realistic scenario: a retail company with sales data segmented by region. Users are regional sales managers and a national account executive who needs full visibility.
Open the SQL query editor in your Fabric Warehouse. We'll create everything in a dedicated schema to keep things organized.
-- Create schemas
CREATE SCHEMA sales;
CREATE SCHEMA security;
GO
-- Main fact table
CREATE TABLE sales.Orders (
OrderID INT NOT NULL,
CustomerID INT NOT NULL,
Region VARCHAR(50) NOT NULL,
SalesRepEmail VARCHAR(255) NOT NULL,
OrderDate DATE NOT NULL,
OrderAmount DECIMAL(12, 2) NOT NULL,
ProductCategory VARCHAR(100) NOT NULL
);
GO
-- Load some realistic test data
INSERT INTO sales.Orders (OrderID, CustomerID, Region, SalesRepEmail, OrderDate, OrderAmount, ProductCategory)
VALUES
(1001, 5001, 'Northeast', 'alice@contoso.com', '2024-01-15', 12500.00, 'Enterprise Software'),
(1002, 5002, 'Northeast', 'alice@contoso.com', '2024-01-22', 8750.50, 'Professional Services'),
(1003, 5003, 'Midwest', 'carlos@contoso.com', '2024-01-18', 22000.00, 'Enterprise Software'),
(1004, 5004, 'Midwest', 'carlos@contoso.com', '2024-01-29', 5400.00, 'Hardware'),
(1005, 5005, 'Southeast', 'diana@contoso.com', '2024-02-03', 17800.00, 'Enterprise Software'),
(1006, 5006, 'West', 'ethan@contoso.com', '2024-02-10', 31000.00, 'Cloud Infrastructure'),
(1007, 5007, 'West', 'ethan@contoso.com', '2024-02-14', 9200.00, 'Professional Services'),
(1008, 5008, 'Northeast', 'alice@contoso.com', '2024-02-20', 14300.00, 'Hardware'),
(1009, 5009, 'Midwest', 'carlos@contoso.com', '2024-03-05', 6700.00, 'Cloud Infrastructure'),
(1010, 5010, 'Southeast', 'diana@contoso.com', '2024-03-12', 19500.00, 'Professional Services');
GO
The mapping table is the core of a dynamic policy — one that doesn't hardcode user names into the function itself. Instead, you maintain a table that maps usernames (specifically, the value that USER_NAME() or SYSTEM_USER returns in the session) to the regions they're allowed to see.
CREATE TABLE security.UserRegionMap (
UserPrincipalName VARCHAR(255) NOT NULL,
Region VARCHAR(50) NOT NULL,
CONSTRAINT PK_UserRegionMap PRIMARY KEY (UserPrincipalName, Region)
);
GO
-- Map users to their regions
-- Note: in Fabric, USER_NAME() returns the Entra ID UPN (email address)
INSERT INTO security.UserRegionMap (UserPrincipalName, Region)
VALUES
('alice@contoso.com', 'Northeast'),
('carlos@contoso.com', 'Midwest'),
('diana@contoso.com', 'Southeast'),
('ethan@contoso.com', 'West'),
-- National account exec sees everything -- we'll add all four regions
('national@contoso.com', 'Northeast'),
('national@contoso.com', 'Southeast'),
('national@contoso.com', 'Midwest'),
('national@contoso.com', 'West');
GO
Note
In a Fabric Warehouse, USER_NAME() returns the Azure Active Directory (Entra ID) user principal name — typically the user's email address. This is different from on-premises SQL Server where it might return a domain\username format. Always verify what your identity context returns before building the mapping table. You can test this with SELECT USER_NAME(), SYSTEM_USER; in your session.
The inline table-valued function (iTVF) is the heart of the policy. You need to understand why an inline TVF specifically — not a scalar function, not a multi-statement TVF.
Inline TVFs are transparent to the query optimizer. The engine can fold the function's logic into the outer query plan, which means it can push predicates and use indexes effectively. Scalar functions and multi-statement TVFs are black boxes from the optimizer's perspective, which causes catastrophic performance degradation — the function gets called once per row, the optimizer can't reason about it, and indexes become useless. Always use an inline TVF for your security predicate.
CREATE FUNCTION security.fn_OrdersRegionPredicate
(
@Region VARCHAR(50)
)
RETURNS TABLE
WITH SCHEMABINDING
AS
RETURN
SELECT 1 AS fn_SecurityResult
FROM security.UserRegionMap AS urm
WHERE urm.Region = @Region
AND urm.UserPrincipalName = USER_NAME();
GO
Let's dissect this carefully:
@Region — this is the column value from the row being evaluatedUserRegionMap to see whether the current user (USER_NAME()) has an entry matching that regionWITH SCHEMABINDING is required for security predicates in Fabric Warehouse — it prevents the underlying tables from being altered in ways that would silently break the functionWarning
Do not put a WHERE 1=1 or always-true condition anywhere in the predicate function as a "fallback." If your function has a logic error that makes it always return a result, every user will see every row. Fail closed, not open. Test your function in isolation before applying the policy.
Test the function in isolation before creating the policy:
-- Simulate what the function returns for the current user and a given region
SELECT *
FROM security.fn_OrdersRegionPredicate('Midwest');
-- If you're logged in as carlos@contoso.com, this should return one row.
-- If you're logged in as alice@contoso.com, this should return nothing.
Now you wire the predicate to the table:
CREATE SECURITY POLICY security.OrdersRegionPolicy
ADD FILTER PREDICATE security.fn_OrdersRegionPredicate(Region)
ON sales.Orders
WITH (STATE = ON);
GO
The policy is now active. Any query against sales.Orders will be automatically filtered by the predicate — for every user, including workspace admins querying through the SQL endpoint, unless they're connecting with a service principal that has special privileges (we'll come back to that).
Verify the policy was created:
SELECT
pol.name AS PolicyName,
pol.is_enabled,
pol.type_desc,
pred.predicate_type_desc,
OBJECT_NAME(pred.target_object_id) AS TargetTable,
pred.predicate_definition
FROM sys.security_policies AS pol
JOIN sys.security_predicates AS pred
ON pol.object_id = pred.object_id;
You should see your OrdersRegionPolicy listed with FILTER as the predicate type.
Tip
Give your security policy and predicate function names that clearly communicate their purpose. security.OrdersRegionPolicy and security.fn_OrdersRegionPredicate make it obvious what they do and where they live. When you have ten policies and twenty functions across a warehouse, naming discipline is what keeps this maintainable.
Here's a subtlety that catches teams off guard: once you enable a security policy, it applies to everyone — including workspace admins querying through the SQL endpoint. If the admin isn't in the UserRegionMap table, they'll see zero rows.
You have two legitimate approaches to handle admin access:
The simplest approach. Add each admin or service account to the mapping table for all regions. The downside is that you need to maintain this list, and if someone's role changes and they're removed from the workspace, you also need to remove them from the mapping table.
You can check for a specific database role membership inside the predicate function:
-- First, create an exemption role
CREATE ROLE rls_bypass;
GO
-- Modify the function to allow role members through
ALTER FUNCTION security.fn_OrdersRegionPredicate
(
@Region VARCHAR(50)
)
RETURNS TABLE
WITH SCHEMABINDING
AS
RETURN
SELECT 1 AS fn_SecurityResult
WHERE IS_ROLEMEMBER('rls_bypass') = 1 -- Bypass for admins
UNION ALL
SELECT 1 AS fn_SecurityResult
FROM security.UserRegionMap AS urm
WHERE urm.Region = @Region
AND urm.UserPrincipalName = USER_NAME()
AND IS_ROLEMEMBER('rls_bypass') = 0; -- Normal users
GO
Then grant the bypass role to specific users:
-- In Fabric Warehouse, users must first exist as database users
-- This typically happens automatically when they access the warehouse
ALTER ROLE rls_bypass ADD MEMBER [admin@contoso.com];
Warning
Be very deliberate about who gets the bypass role. The purpose of RLS is data segregation. Every person added to the bypass role is a potential data governance exception that needs to be documented and justified. In regulated industries (healthcare, finance), these exceptions may need to be audited.
The Lakehouse SQL Analytics Endpoint is where the story gets meaningfully different — and where many teams discover, after the fact, that their security assumptions were wrong.
The SQL Analytics Endpoint lets you query Delta tables in a lakehouse using T-SQL, as covered in Querying Lakehouse Data with the SQL Analytics Endpoint: Writing T-SQL Against Delta Tables Without a Warehouse. It presents a SQL interface over the Delta tables that live in OneLake. But the security model has important constraints.
As of current Fabric releases, the SQL Analytics Endpoint does support RLS via the same CREATE SECURITY POLICY mechanism. You can create filter predicates on tables that the endpoint exposes. The syntax is identical to the warehouse.
Here's how you'd apply the same pattern to a lakehouse endpoint:
-- In the SQL Analytics Endpoint query editor
-- Create the mapping table
-- Note: This creates a managed table in the lakehouse, which becomes a Delta table in OneLake
CREATE TABLE security_UserRegionMap (
UserPrincipalName VARCHAR(255) NOT NULL,
Region VARCHAR(50) NOT NULL
);
INSERT INTO security_UserRegionMap VALUES
('alice@contoso.com', 'Northeast'),
('carlos@contoso.com', 'Midwest'),
('diana@contoso.com', 'Southeast'),
('ethan@contoso.com', 'West');
-- Create the predicate function
CREATE FUNCTION fn_LakehouseOrdersPredicate
(
@Region VARCHAR(50)
)
RETURNS TABLE
WITH SCHEMABINDING
AS
RETURN
SELECT 1 AS fn_SecurityResult
FROM dbo.security_UserRegionMap AS urm
WHERE urm.Region = @Region
AND urm.UserPrincipalName = USER_NAME();
-- Apply the policy
CREATE SECURITY POLICY LakehouseOrdersPolicy
ADD FILTER PREDICATE fn_LakehouseOrdersPredicate(Region)
ON dbo.orders
WITH (STATE = ON);
Limitation 1: Schemas are flat in the SQL Analytics Endpoint. Unlike the warehouse, the SQL Analytics Endpoint uses dbo as its default schema and doesn't support creating custom schemas for organization. This means you can't use the security. schema prefix convention — everything goes into dbo. Plan your naming conventions accordingly.
Limitation 2: The endpoint is read-only for Delta tables synchronized from the lakehouse. You can't create RLS on tables that were created by Spark or Dataflow Gen2 and then auto-discovered by the endpoint — you can only query those. RLS applies to tables that are managed through the SQL endpoint itself. This is a subtle but important distinction.
Limitation 3: Direct OneLake access bypasses the SQL endpoint entirely. If a user has access to the underlying OneLake storage (through the Files API, through a Spark notebook, or through a shortcut), the SQL endpoint's RLS policies are completely irrelevant. RLS only governs access through the SQL interface. This is not a quirk — it's by design — but it's a critical gap if you're relying on the endpoint for data governance.
Key insight
The SQL Analytics Endpoint enforces RLS for SQL-path queries only. If your users interact with the lakehouse through Power BI in Direct Lake mode, or through Spark notebooks, the RLS policies you've defined at the SQL layer do not automatically apply. Each access path needs its own security story. If you're building Direct Lake reports, read about Direct Lake Mode in Power BI: How It Works and When to Use It over Import and DirectQuery to understand where security controls sit in that model.
Limitation 4: RLS in the SQL Analytics Endpoint does not propagate to Power BI semantic models built on top of it. When Power BI queries the endpoint, it uses the service account identity for Direct Lake connections, not the individual user's identity. Your SQL-level RLS won't filter rows in Power BI reports unless you also implement RLS in the Power BI semantic model itself.
This is where the vast majority of security implementations go wrong. Here's the critical rule:
Workspace roles grant access to the workspace and its items. RLS controls what data within those items a user can see. These are two independent layers, and one does not substitute for the other.
Consider what happens when you grant someone the Viewer workspace role. They can see the warehouse exists, open the SQL editor, and run queries. If you have RLS enabled, they'll see filtered data based on their identity. Good so far.
But consider the Member and Contributor roles. Users in these roles have ALTER permissions on objects within the workspace. A Contributor can potentially:
WITH SCHEMABINDING, but only if you used it)Warning
Never rely on RLS alone for users with Contributor or higher workspace roles. Users who need data access for reporting should be granted the Viewer role or granted explicit item-level permissions with read-only access. Reserve Contributor and above for users who actually need to build and modify artifacts in the workspace.
Here's a concrete example of the problem. Suppose Alice has the Contributor role (so she can build reports) but should only see the Northeast region. She runs:
-- Alice can create a view that cross-joins the secured table with itself
-- The view inherits RLS, so this particular attack doesn't work
-- But she could run aggregations that reveal regional totals through inference:
SELECT ProductCategory, COUNT(*) as OrderCount, SUM(OrderAmount) as TotalAmount
FROM sales.Orders
GROUP BY ProductCategory;
-- This returns ONLY her region's rows because RLS is applied before GROUP BY
-- The aggregated numbers don't reveal individual rows but might reveal more than intended
The view-based attack actually doesn't work because RLS applies underneath views — views inherit the security policy. But aggregations on filtered data can still leak information if the data cardinality is low enough. This is a data inference problem, not an RLS bypass, but it's worth understanding.
Here's the layered access model you should implement:
Layer 1: Entra ID Group Membership
→ Who can access the workspace at all?
→ Managed in Azure Active Directory / Entra ID
Layer 2: Workspace Role (Viewer for most business users)
→ Can they read but not modify workspace artifacts?
→ Managed in Fabric workspace settings
Layer 3: Item-Level Permissions (Read on specific warehouses/lakehouses)
→ Can they connect to this specific data store?
→ Managed via "Manage permissions" on the item
Layer 4: RLS Policy (Dynamic row filtering)
→ Which rows can they see within the tables they can query?
→ Managed via T-SQL security policies
Each layer is a gate. You want users passing through all four layers, with each layer enforcing its specific constraint.
This is where most implementations reveal their flaws. You've written the policy, checked the sys.security_policies catalog, and called it done. But you've only tested as yourself — an admin who can see everything. The real test is verifying the policy from the perspective of a user with restricted access.
The EXECUTE AS USER statement lets you temporarily assume another user's identity within your current session. This is the fastest way to verify policy behavior without actually logging in as another user.
-- First, verify current user sees all rows (assumes admin is in bypass role or all regions)
SELECT Region, COUNT(*) as RowCount
FROM sales.Orders
GROUP BY Region;
-- Expected: Northeast: 3, Midwest: 3, Southeast: 2, West: 2
-- Now impersonate Carlos (Midwest only)
EXECUTE AS USER = 'carlos@contoso.com';
SELECT USER_NAME(); -- Should return 'carlos@contoso.com'
SELECT Region, COUNT(*) as RowCount
FROM sales.Orders
GROUP BY Region;
-- Expected: Midwest: 3 (only Carlos's rows)
SELECT * FROM sales.Orders;
-- Expected: only the 3 Midwest rows
-- Try to access data from another region directly
SELECT * FROM sales.Orders WHERE Region = 'Northeast';
-- Expected: 0 rows returned (not an error — RLS silently filters)
-- Return to original identity
REVERT;
SELECT USER_NAME(); -- Should return your original UPN
Tip
Always pair EXECUTE AS USER with REVERT in your testing scripts. If you run EXECUTE AS USER and then close the query window without reverting, the session might retain the impersonated identity depending on how the connection is pooled. Make it a habit to explicitly revert before finishing any impersonation test block.
Before you even create the policy, test the function with simulated identity:
-- Test the function as if you were Carlos
EXECUTE AS USER = 'carlos@contoso.com';
-- The function should return a row for Midwest but not for Northeast
SELECT 'Midwest' as TestRegion, COUNT(*) as ShouldBeOne
FROM security.fn_OrdersRegionPredicate('Midwest');
SELECT 'Northeast' as TestRegion, COUNT(*) as ShouldBeZero
FROM security.fn_OrdersRegionPredicate('Northeast');
SELECT 'West' as TestRegion, COUNT(*) as ShouldBeZero
FROM security.fn_OrdersRegionPredicate('West');
REVERT;
This isolates the predicate logic from the full policy, making it easy to debug whether failures are in the function or in the policy wiring.
EXECUTE AS USER is convenient, but it's a simulation. The true validation is to have an actual restricted user log in and run queries. This catches problems that impersonation can miss:
Here's how to structure external user testing systematically:
Step 1: Create a test user in your Entra ID tenant. Name them something obvious like rls-test-northeast@yourtenantdomain.com. Add them to the UserRegionMap for Northeast only.
Step 2: Grant the user Viewer access to the workspace. Do this through workspace settings, not by making them an admin.
Step 3: Grant the user Read permission on the specific warehouse item. In the workspace, find the warehouse, select "Manage permissions," and add the test user with Read permissions (which maps to CONNECT on the database).
Step 4: Have the test user (or you, logged in a separate browser session in InPrivate/Incognito mode) execute your validation script:
-- Test script to be run by the restricted user
SELECT USER_NAME() AS MyIdentity;
-- Should return only Northeast rows
SELECT * FROM sales.Orders;
-- Count by region - should only show 'Northeast'
SELECT Region, COUNT(*) as OrderCount
FROM sales.Orders
GROUP BY Region;
-- Try an explicit cross-region filter - should return 0 rows, not an error
SELECT * FROM sales.Orders WHERE Region = 'Midwest';
-- Aggregate query - should reflect only Northeast totals
SELECT
SUM(OrderAmount) AS TotalRevenue,
AVG(OrderAmount) AS AvgOrderSize
FROM sales.Orders;
Step 5: Document the expected and actual outputs for each query. This becomes your RLS test suite — run it every time you modify the policy, add a new predicate, or change the mapping table.
Real-world scenarios often require more nuanced filtering than a single column. A common pattern: filter by region AND by whether the user is the sales rep on the order.
-- More complex mapping table
CREATE TABLE security.UserAccessMap (
UserPrincipalName VARCHAR(255) NOT NULL,
Region VARCHAR(50) NULL, -- NULL means all regions
SalesRepEmail VARCHAR(255) NULL, -- NULL means all reps
AccessLevel VARCHAR(20) NOT NULL, -- 'regional', 'rep', 'global'
CONSTRAINT PK_UserAccessMap PRIMARY KEY (UserPrincipalName, AccessLevel)
);
GO
INSERT INTO security.UserAccessMap VALUES
('alice@contoso.com', 'Northeast', 'alice@contoso.com', 'rep'),
('mgr.northeast@c.com', 'Northeast', NULL, 'regional'),
('national@contoso.com', NULL, NULL, 'global');
GO
-- Compound predicate function
CREATE FUNCTION security.fn_OrdersCompoundPredicate
(
@Region VARCHAR(50),
@SalesRepEmail VARCHAR(255)
)
RETURNS TABLE
WITH SCHEMABINDING
AS
RETURN
SELECT 1 AS fn_SecurityResult
FROM security.UserAccessMap AS uam
WHERE uam.UserPrincipalName = USER_NAME()
AND (
uam.AccessLevel = 'global' -- Global access
OR (uam.AccessLevel = 'regional' AND uam.Region = @Region) -- Regional filter
OR (uam.AccessLevel = 'rep' AND uam.SalesRepEmail = @SalesRepEmail) -- Own rows only
);
GO
-- Wire the compound predicate to the table
-- First disable the existing policy
ALTER SECURITY POLICY security.OrdersRegionPolicy WITH (STATE = OFF);
DROP SECURITY POLICY security.OrdersRegionPolicy;
DROP FUNCTION security.fn_OrdersRegionPredicate;
CREATE SECURITY POLICY security.OrdersPolicy
ADD FILTER PREDICATE security.fn_OrdersCompoundPredicate(Region, SalesRepEmail)
ON sales.Orders
WITH (STATE = ON);
GO
Test the compound predicate carefully:
-- Test as Alice (rep-level access, Northeast only, own rows only)
EXECUTE AS USER = 'alice@contoso.com';
SELECT OrderID, Region, SalesRepEmail, OrderAmount FROM sales.Orders;
-- Should return only rows where SalesRepEmail = 'alice@contoso.com'
REVERT;
-- Test as Northeast manager (regional access, all Northeast rows)
EXECUTE AS USER = 'mgr.northeast@c.com';
SELECT OrderID, Region, SalesRepEmail, OrderAmount FROM sales.Orders;
-- Should return all Northeast rows regardless of sales rep
REVERT;
-- Test as national exec (global access, all rows)
EXECUTE AS USER = 'national@contoso.com';
SELECT OrderID, Region, SalesRepEmail, OrderAmount FROM sales.Orders;
-- Should return all rows
REVERT;
Even with inline TVFs, a predicate that can't leverage indexes will cause full table scans on your mapping table for every row evaluation. Make sure your mapping table is properly indexed:
-- Index to support the most common lookup pattern
CREATE INDEX IX_UserRegionMap_UPN_Region
ON security.UserRegionMap (UserPrincipalName, Region);
For very large fact tables (hundreds of millions of rows), test your query plans with RLS enabled versus disabled. Use SET STATISTICS IO ON to compare logical reads. If the plan shows a significant increase in reads with RLS enabled, your predicate function may be preventing index seeks on the fact table itself.
Fabric Warehouse uses V-Order and columnar storage under the hood. RLS filter predicates interact with columnar storage differently than row-store indexes. The engine may read entire row groups and then apply the predicate in memory, rather than skipping row groups based on statistics. This means RLS overhead on a warehouse with very large tables can be non-trivial. Always benchmark with realistic data volumes.
Key insight
If your RLS predicate filters by a column that's also a common partition or clustering key, performance will be significantly better because the engine can eliminate large amounts of data before even evaluating the predicate. Design your Delta table partitioning (for lakehouses) and your table organization (for warehouses) to align with your security boundaries where possible.
Some teams create views that bake the RLS logic in:
-- Anti-pattern: Don't do this
CREATE VIEW sales.Northeast_Orders AS
SELECT * FROM sales.Orders WHERE Region = 'Northeast';
This seems to work but creates an unmaintainable mess:
The CREATE SECURITY POLICY approach is demonstrably better: one policy object, one function, one mapping table. Auditors can see exactly what's in place. DBAs can modify it in one place.
Dynamic Data Masking (DDM) is often confused with RLS. They solve different problems:
XXX-XX-1234 instead of a full SSN) but returns the rowYou can use both simultaneously. A user might be able to see the row (RLS says yes) but see a masked version of sensitive columns (DDM says mask). Fabric Warehouse supports DDM. But implementing both is a configuration management challenge — make sure your test cases cover both dimensions.
This exercise consolidates everything you've learned into a single end-to-end implementation. Budget about 45-60 minutes.
You're building data access controls for a healthcare analytics platform. The clinical.PatientVisits table contains patient visit records segmented by hospital department. Department managers should only see their own department's records. Medical directors see all departments in their hospital. The Chief Medical Officer sees everything.
CREATE SCHEMA clinical;
CREATE SCHEMA rls_security;
GO
CREATE TABLE clinical.PatientVisits (
VisitID INT NOT NULL,
PatientID INT NOT NULL,
Department VARCHAR(100) NOT NULL,
Hospital VARCHAR(100) NOT NULL,
VisitDate DATE NOT NULL,
DiagnosisCode VARCHAR(20) NOT NULL,
BilledAmount DECIMAL(12,2) NOT NULL
);
GO
INSERT INTO clinical.PatientVisits VALUES
(1, 101, 'Cardiology', 'City General', '2024-01-10', 'I21.3', 4500.00),
(2, 102, 'Oncology', 'City General', '2024-01-12', 'C34.10', 12000.00),
(3, 103, 'Cardiology', 'City General', '2024-01-15', 'I50.9', 8200.00),
(4, 104, 'Emergency', 'City General', '2024-01-18', 'S72.001',3200.00),
(5, 105, 'Cardiology', 'Metro Hospital', '2024-01-20', 'I25.110',7800.00),
(6, 106, 'Neurology', 'Metro Hospital', '2024-01-22', 'G35', 9500.00),
(7, 107, 'Emergency', 'Metro Hospital', '2024-01-25', 'T14.90', 2100.00),
(8, 108, 'Oncology', 'Metro Hospital', '2024-02-01', 'C50.912',15000.00);
GO
Design and create a rls_security.UserDeptAccess table that supports three access levels: department, hospital, and global. Insert at least five test users with different access combinations.
Write an inline TVF rls_security.fn_PatientVisitsPredicate that:
@Department VARCHAR(100) and @Hospital VARCHAR(100) as parametersApply the predicate as a filter on clinical.PatientVisits. Verify it appears in sys.security_policies.
Write a test script using EXECUTE AS USER / REVERT blocks that validates:
Practice the operational pattern:
-- Disable for maintenance (e.g., during bulk load)
ALTER SECURITY POLICY rls_security.PatientVisitsPolicy WITH (STATE = OFF);
-- Verify all rows visible during maintenance
SELECT Hospital, Department, COUNT(*) as RowCount
FROM clinical.PatientVisits
GROUP BY Hospital, Department;
-- Re-enable
ALTER SECURITY POLICY rls_security.PatientVisitsPolicy WITH (STATE = ON);
Mistake 1: Policy created but user sees zero rows instead of their rows.
Cause: The user's UPN in USER_NAME() doesn't exactly match what's in the mapping table. Common culprits: case sensitivity differences, spaces, or domain aliases (e.g., user logs in as User@Contoso.Com but the table has user@contoso.com).
Fix: Run SELECT USER_NAME() in the session first, then compare to what's in the mapping table. Add a LOWER() call in both the function and the insert to normalize.
-- Defensive version of the predicate
CREATE FUNCTION security.fn_OrdersRegionPredicate(@Region VARCHAR(50))
RETURNS TABLE
WITH SCHEMABINDING
AS
RETURN
SELECT 1 AS fn_SecurityResult
FROM security.UserRegionMap AS urm
WHERE urm.Region = @Region
AND LOWER(urm.UserPrincipalName) = LOWER(USER_NAME());
Mistake 2: Policy is ON but doesn't seem to filter for service principals or pipelines.
Cause: Pipelines and Spark notebooks that connect to the warehouse may use a service principal identity, and that identity may have the db_owner or dbo role, which bypasses RLS in some database engines.
Fix: In Fabric Warehouse, RLS applies even to db_owner unless you explicitly create a bypass mechanism. Verify what USER_NAME() returns in the pipeline's connection context. Add the service principal's object ID or UPN to the mapping table if needed, or use the bypass role pattern described earlier.
Mistake 3: Dropped and recreated the underlying table and the policy is now broken.
Cause: DROP TABLE followed by CREATE TABLE destroys the security policy binding. The policy still exists but references a nonexistent object ID, and queries will fail.
Fix: Use ALTER TABLE to modify columns rather than dropping and recreating. If you must recreate, script the policy drop and recreation as part of your deployment. This is another reason to manage your security policies in version-controlled scripts — ideally through Fabric Git Integration and Deployment Pipelines: Version Control and Promotion Across Environments.
Mistake 4: RLS works in the warehouse but not through Power BI Direct Lake.
Cause: This is not a mistake — it's expected behavior. Power BI Direct Lake connects to the lakehouse, not the warehouse, using a system identity. The SQL endpoint's RLS policies don't govern this path.
Fix: Implement RLS in the Power BI semantic model using DAX roles. This is a separate layer of security and should be considered complementary, not redundant. The two layers together — SQL-level RLS for direct SQL access, DAX-level RLS for Power BI — provide defense in depth. See how the lakehouse connects to Power BI in Connecting a Power BI Semantic Model to a Fabric Lakehouse in Direct Lake Mode: Creating, Refreshing, and Optimizing Delta Tables for Reporting and plan your security model accordingly.
Mistake 5: The predicate function was created without WITH SCHEMABINDING.
Cause: Without SCHEMABINDING, someone can alter or drop the underlying mapping table without removing the function first. The function becomes a reference to a nonexistent object, and queries against the secured table will fail with cryptic errors.
Fix: Always use WITH SCHEMABINDING. If you need to modify the mapping table schema, you'll need to drop the function first, alter the table, then recreate the function. This is a feature, not a bug — it forces deliberate action before breaking a security control.
Mistake 6: Testing only shows the right result for one user but the policy is incorrect for another.
Cause: Testing is incomplete. Teams often test the "happy path" (user sees their rows) but forget to test:
Fix: Build a comprehensive test matrix before going to production. Test every access level combination, including edge cases like "user in zero regions" and "user in all regions."
You've built a complete, production-grade row-level security implementation from scratch. Let's recap the architecture you now own:
WITH SCHEMABINDING that the query optimizer can reason about efficientlyEXECUTE AS USER for in-session validation and real user testing for production verificationYou also now understand the critical distinctions:
If you're working on a medallion architecture and wondering where RLS fits into the bronze/silver/gold layers, you'll find the design decisions in Implementing the Medallion Architecture in Microsoft Fabric: Bronze, Silver, and Gold Layers — specifically, the gold layer is where RLS typically lives, on curated tables that business users query directly.
For teams managing multiple environments (dev, test, prod), your security policies need to be version controlled and deployed consistently. Your RLS scripts should be committed to Git and promoted through deployment pipelines alongside the table definitions they govern — the same pipeline that promotes your warehouse schema should also promote your security policies.
And if your organization is building Direct Lake Power BI reports on secured data, understand that you're working with two independent security stacks: T-SQL RLS for SQL-path access, and Power BI DAX roles for the report layer. Both need to be configured, tested, and maintained. A gap in either one is a data governance exposure.
RLS is one of those capabilities that looks straightforward in a 10-line example and reveals its depth when you're managing fifty users across eight tables in three environments. The architecture you've built here — separation of mapping data from predicate logic, schema-bound functions, systematic testing with impersonation — scales to that complexity. Start simple, stay systematic, and test from the user's perspective before you ship.
Microsoft Fabric Fundamentals
Parameterizing Dataflow Gen2 Queries with Pipeline Integration: Passing Dynamic Values to Power Query for Reusable Ingestion Flows
Creating and Managing Fabric Lakehouses with Notebooks: Reading External Files from OneLake, Writing Delta Tables, and Browsing Results in the Lakehouse Explorer