How to build row-level security with an inline predicate function and a security policy, and how filter and block predicates differ.
From Ultra Transcenders DP-800 by Tony Rough (coming December 2026)
Row-level security (RLS) restricts access to rows based on the caller’s identity or execution context, with the logic held in the database rather than the application. It’s built from an inline table-valued function (the predicate function) bound to tables by a security policy. It’s available in SQL Server, Azure SQL Database, Azure SQL Managed Instance, SQL database in Fabric, and Fabric Warehouse and SQL analytics endpoint. Figure 7.2 traces a read and an insert through the policy built in this section.
| Predicate | Applies to | Effect |
|---|---|---|
| Filter | Reads by SELECT, UPDATE, DELETE |
Rows silently disappear; the app isn’t told |
Block AFTER INSERT |
New rows | Insert fails if the new row violates the predicate |
Block AFTER UPDATE |
Updated rows | Prevents updating a row to a value that violates the predicate |
Block BEFORE UPDATE |
Existing rows | Prevents updating rows that currently violate it |
Block BEFORE DELETE |
Existing rows | Prevents deleting rows that violate it |
A filter predicate alone still lets users insert rows they then can’t see, and update a visible row so that it becomes filtered; add block predicates to stop that. The optimiser doesn’t check an AFTER UPDATE block predicate when the columns it uses weren’t changed. Block predicates apply to BULK INSERT too. The RLS page states that Microsoft Fabric and Azure Synapse Analytics support filter predicates only; that note sits beside the Warehouse example, while the Fabric SQL database feature list simply marks RLS as supported, so test block predicates there before relying on them.
When a middle tier connects as one database user, it can store the end user’s identity with sp_set_session_context and the predicate can read it with SESSION_CONTEXT. @read_only = 1 stops the value changing until the connection closes.
CREATE SCHEMA Security;
GO
CREATE FUNCTION Security.fn_TenantPredicate(@TenantId int)
RETURNS TABLE WITH SCHEMABINDING
AS RETURN SELECT 1 AS ok
WHERE DATABASE_PRINCIPAL_ID() = DATABASE_PRINCIPAL_ID('AppUser')
AND CAST(SESSION_CONTEXT(N'TenantId') AS int) = @TenantId;
GO
CREATE SECURITY POLICY Security.TenantFilter
ADD FILTER PREDICATE Security.fn_TenantPredicate(TenantId) ON dbo.Orders,
ADD BLOCK PREDICATE Security.fn_TenantPredicate(TenantId) ON dbo.Orders AFTER INSERT
WITH (STATE = ON);
-- In the application, after opening the connection:
EXEC sp_set_session_context @key = N'TenantId', @value = 42, @read_only = 1;STATE = ON enables the policy; ALTER SECURITY POLICY ... WITH (STATE = OFF) disables it. Data API builder can pass token claims into SESSION_CONTEXT in the same way (see “Secure access, auditing and securing endpoints”).
ALTER ANY SECURITY POLICY, plus ALTER on the schema; each predicate also needs SELECT and REFERENCES on the function, and REFERENCES on the target table and its predicate columns.SCHEMABINDING = ON is the default for policies: joins and functions inside the predicate work without extra permission checks for querying users. With SCHEMABINDING = OFF, users need SELECT or EXECUTE on the function and anything it references.dbo and db_owner; if administrators must see all rows, write that into the predicate.SET options such as DATEFORMAT or DATEFIRST.WITH NATIVE_COMPILATION.DBCC SHOW_STATISTICS is restricted because statistics reflect unfiltered data.db_owner and the gating role; change tracking can expose primary keys of filtered rows.SELECT 1/(Salary-100000) reveals a value through a divide-by-zero error.RLS decides which rows come back; DDM changes how column values in those rows are displayed. A filtered-out row is never masked because it’s never returned, and a returned row shows masked columns unless the caller holds UNMASK.
Common trap: Adding only a filter predicate and assuming users can’t write other tenants’ rows - filter predicates don’t stop inserts. Add an
AFTER INSERT(and, where needed,AFTER UPDATE) block predicate, or deny updates to the tenant column.
Common trap: Expecting
db_ownerto bypass a security policy - policies filter or block rows fordbo,db_ownerand table owners as well; exemptions must be written into the predicate function.
This note is one section of Ultra Transcenders DP-800: Developing AI-Enabled Database Solutions, an independent study guide that explains every topic the exam covers by technology, with comparison tables, diagrams and the common traps, plus a glossary linked to Microsoft Learn.
Due on Amazon in December 2026, in Kindle and paperback editions.
About the book · DP-800 terms in the glossary · All DP-800 study notes
How each isolation level trades consistency for concurrency, when to use RCSI or snapshot isolation, and which anomalies each one prevents.
How Always Encrypted keeps keys away from the database engine, deterministic versus randomised encryption, and secure enclaves.
How the OVER clause partitions, orders and frames rows, and how ROWS and RANGE frames decide which rows each calculation sees.
How temporal tables keep row history automatically, and how FOR SYSTEM_TIME AS OF, BETWEEN and ALL query it.
When exact k-nearest-neighbour search is enough, when an approximate DiskANN vector index pays off, and why its metric must match the query.
How to combine full-text and vector search results with reciprocal rank fusion in T-SQL, and why RRF uses ranks rather than raw scores.
Choosing between change event streaming, change data capture, change tracking, Azure Functions and Logic Apps to react to row changes.